From e513baf70a20306856e40fbf2afd80b25f5655a6 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Thu, 21 May 2026 18:41:18 -0400 Subject: [PATCH 01/40] feat: initial release --- .github/workflows/release.yml | 107 + .gitignore | 7 + .releaserc.cjs | 33 + AGENTS.md | 316 + CHANGELOG.md | 3 + LICENSE | 661 ++ README.md | 196 + __init__.py | 49 + eslint.config.js | 22 + package-lock.json | 9516 +++++++++++++++++ package.json | 31 + pyproject.toml | 67 + requirements.txt | 8 + scripts/update-release-versions.mjs | 48 + simple_syrup/__init__.py | 11 + simple_syrup/domain/__init__.py | 5 + simple_syrup/domain/conditioning_batch.py | 62 + simple_syrup/domain/detail_geometry.py | 64 + simple_syrup/domain/graph_provenance.py | 38 + simple_syrup/domain/prompt_composition.py | 19 + simple_syrup/domain/prompt_style.py | 40 + simple_syrup/domain/regional_detailing.py | 153 + simple_syrup/domain/segs.py | 306 + simple_syrup/domain/tile_segs.py | 247 + simple_syrup/domain/tiled_diffusion.py | 230 + simple_syrup/image/__init__.py | 9 + simple_syrup/image/crop_composite.py | 40 + simple_syrup/image/resize_geometry.py | 375 + simple_syrup/image/resize_service.py | 369 + simple_syrup/masking/__init__.py | 9 + simple_syrup/masking/mask_ops.py | 279 + .../masking/prompt_segs_with_sam_service.py | 402 + .../masking/regional_detailing_masks.py | 263 + simple_syrup/masking/segs_mask_ops.py | 197 + simple_syrup/nodes/__init__.py | 137 + simple_syrup/nodes/conditioning_batch_pack.py | 88 + simple_syrup/nodes/detail_segs_as_regions.py | 275 + .../nodes/detail_segs_by_scale_factor.py | 245 + ...il_segs_by_scale_factor_tiled_diffusion.py | 328 + simple_syrup/nodes/detailer_input_adapters.py | 155 + .../nodes/detect_segs_with_ultralytics.py | 213 + simple_syrup/nodes/encode_prompt_batch.py | 93 + simple_syrup/nodes/grounded_sam_model_info.py | 56 + .../nodes/grounding_dino_model_loader.py | 76 + simple_syrup/nodes/image_resize_to_target.py | 218 + simple_syrup/nodes/ksampler_extras.py | 181 + .../nodes/ksampler_tiled_diffusion.py | 197 + simple_syrup/nodes/latent_diagnostics.py | 64 + .../nodes/layerstyle_sam_models_adapter.py | 50 + simple_syrup/nodes/load_ultralytics_model.py | 54 + simple_syrup/nodes/prompt_encode_style.py | 45 + .../prompt_encode_style_and_normalization.py | 64 + simple_syrup/nodes/prompt_segs_with_sam.py | 341 + simple_syrup/nodes/provenance_latent.py | 278 + simple_syrup/nodes/sam_model_loader.py | 60 + simple_syrup/nodes/scale_factor.py | 63 + simple_syrup/nodes/seed.py | 46 + simple_syrup/nodes/simple_load_anima.py | 133 + simple_syrup/nodes/simple_load_checkpoint.py | 96 + simple_syrup/nodes/tile_and_tag_segs.py | 255 + simple_syrup/nodes/tooltips.py | 190 + simple_syrup/nodes/vitmatte_model_loader.py | 63 + simple_syrup/nodes/wd14_tagger_loader.py | 61 + simple_syrup/nodes_v3/__init__.py | 41 + ...encode_prompt_batch_with_prompt_control.py | 113 + simple_syrup/nodes_v3/scale_factor.py | 70 + .../nodes_v3/simple_load_checkpoint.py | 99 + simple_syrup/nodes_v3/tile_and_tag_segs.py | 194 + simple_syrup/nodes_v3/wd14_tagger_loader.py | 68 + simple_syrup/runtime/__init__.py | 9 + simple_syrup/runtime/a1111_sampling.py | 145 + simple_syrup/runtime/anima_loader.py | 191 + simple_syrup/runtime/auto_model_cache.py | 215 + simple_syrup/runtime/auto_model_resolver.py | 253 + simple_syrup/runtime/bert_resolver.py | 136 + simple_syrup/runtime/checkpoint_loader.py | 123 + .../runtime/comfy_graph_provenance.py | 209 + simple_syrup/runtime/conditioning_encoding.py | 31 + simple_syrup/runtime/detail_previews.py | 485 + simple_syrup/runtime/detail_resize.py | 170 + simple_syrup/runtime/detail_sampling.py | 153 + simple_syrup/runtime/detector_compat.py | 77 + simple_syrup/runtime/grounding_dino_loader.py | 365 + simple_syrup/runtime/image_resamplers.py | 101 + simple_syrup/runtime/loaded_models.py | 99 + .../runtime/mixture_of_diffusers_sampling.py | 330 + simple_syrup/runtime/model_catalog.py | 475 + simple_syrup/runtime/model_choices.py | 173 + simple_syrup/runtime/model_device_manager.py | 277 + simple_syrup/runtime/model_downloads.py | 244 + simple_syrup/runtime/model_folders.py | 195 + simple_syrup/runtime/model_instance_cache.py | 34 + simple_syrup/runtime/model_metadata.py | 145 + .../runtime/multidiffusion_sampling.py | 317 + simple_syrup/runtime/progress.py | 33 + .../runtime/prompt_control_availability.py | 67 + .../runtime/prompt_control_batch_graph.py | 133 + .../regional_multidiffusion_sampling.py | 555 + simple_syrup/runtime/sam_loader.py | 215 + simple_syrup/runtime/sam_segmenter.py | 209 + simple_syrup/runtime/sampling_samplers.py | 90 + simple_syrup/runtime/sampling_schedulers.py | 590 + simple_syrup/runtime/settings.py | 136 + simple_syrup/runtime/settings_routes.py | 115 + simple_syrup/runtime/text_box_detector.py | 238 + simple_syrup/runtime/tiled_sampling.py | 315 + simple_syrup/runtime/torchlanc_resampler.py | 92 + simple_syrup/runtime/ultralytics_detection.py | 175 + simple_syrup/runtime/ultralytics_loader.py | 426 + simple_syrup/runtime/vae_loader.py | 174 + simple_syrup/runtime/vitmatte_loader.py | 272 + simple_syrup/runtime/vitmatte_refiner.py | 287 + simple_syrup/runtime/wd14_tagger.py | 260 + simple_syrup/runtime/wd14_tagger_loader.py | 288 + simple_syrup/services/__init__.py | 9 + .../detail_segs_as_regions_service.py | 419 + .../detail_segs_by_scale_factor_service.py | 309 + ...by_scale_factor_tiled_diffusion_service.py | 444 + .../services/latent_diagnostics_service.py | 217 + .../services/segs_detection_service.py | 245 + simple_syrup/services/segs_output_service.py | 92 + .../services/tile_and_tag_segs_service.py | 143 + .../tiled_diffusion_sampling_service.py | 78 + simple_syrup/shared/__init__.py | 9 + simple_syrup/shared/logging.py | 15 + simple_syrup/shared/tensor_validation.py | 84 + simple_syrup/third_party/__init__.py | 1 + .../groundingdino_runtime/__init__.py | 1 + .../datasets/__init__.py | 0 .../datasets/transforms.py | 312 + .../models/GroundingDINO/__init__.py | 16 + .../models/GroundingDINO/backbone/__init__.py | 2 + .../models/GroundingDINO/backbone/backbone.py | 222 + .../backbone/position_encoding.py | 187 + .../backbone/swin_transformer.py | 803 ++ .../models/GroundingDINO/bertwarper.py | 270 + .../models/GroundingDINO/fuse_modules.py | 298 + .../models/GroundingDINO/groundingdino.py | 386 + .../models/GroundingDINO/ms_deform_attn.py | 335 + .../models/GroundingDINO/transformer.py | 960 ++ .../GroundingDINO/transformer_vanilla.py | 119 + .../models/GroundingDINO/utils.py | 269 + .../groundingdino_runtime/models/__init__.py | 19 + .../groundingdino_runtime/models/registry.py | 67 + .../groundingdino_runtime/util/__init__.py | 2 + .../groundingdino_runtime/util/box_ops.py | 141 + .../util/get_tokenlizer.py | 30 + .../groundingdino_runtime/util/inference.py | 245 + .../groundingdino_runtime/util/misc.py | 718 ++ .../groundingdino_runtime/util/slconfig.py | 428 + .../groundingdino_runtime/util/slio.py | 178 + .../groundingdino_runtime/util/utils.py | 609 ++ .../third_party/sam_hq_runtime/__init__.py | 2 + .../third_party/sam_hq_runtime/automatic.py | 115 + .../sam_hq_runtime/build_sam_hq.py | 165 + .../sam_hq_runtime/modeling/__init__.py | 2 + .../sam_hq_runtime/modeling/image_encoder.py | 20 + .../modeling/mask_decoder_hq.py | 236 + .../sam_hq_runtime/modeling/tiny_vit.py | 617 ++ .../third_party/sam_hq_runtime/predictor.py | 145 + test_helpers.py | 54 + tests/conftest.py | 25 + tests/test_a1111_sampling.py | 197 + tests/test_anima_loader.py | 306 + tests/test_auto_model_cache.py | 132 + tests/test_auto_model_resolver.py | 225 + tests/test_bert_resolver.py | 116 + tests/test_checkpoint_loader.py | 275 + tests/test_conditioning_batch.py | 79 + tests/test_conditioning_batch_pack_node.py | 61 + tests/test_detail_geometry.py | 74 + tests/test_detail_previews.py | 349 + tests/test_detail_resize.py | 137 + tests/test_detail_segs_as_regions_node.py | 341 + tests/test_detail_segs_as_regions_service.py | 596 ++ .../test_detail_segs_by_scale_factor_node.py | 306 + ...est_detail_segs_by_scale_factor_service.py | 594 + ...gs_by_scale_factor_tiled_diffusion_node.py | 212 + ...by_scale_factor_tiled_diffusion_service.py | 443 + .../test_detect_segs_with_ultralytics_node.py | 466 + tests/test_detector_compat.py | 121 + tests/test_encode_prompt_batch_node.py | 90 + ...e_prompt_batch_with_prompt_control_node.py | 50 + tests/test_graph_provenance.py | 221 + tests/test_grounded_sam_model_info_node.py | 55 + tests/test_grounding_dino_loader.py | 343 + .../test_grounding_dino_model_loader_node.py | 146 + tests/test_ksampler_extras_node.py | 291 + tests/test_ksampler_tiled_diffusion_node.py | 195 + tests/test_latent_diagnostics_node.py | 45 + tests/test_latent_diagnostics_service.py | 86 + ...test_layerstyle_sam_models_adapter_node.py | 39 + tests/test_license_headers.py | 82 + tests/test_load_ultralytics_model_node.py | 57 + tests/test_loaded_models.py | 114 + tests/test_mask_ops.py | 113 + tests/test_mixture_of_diffusers_sampling.py | 638 ++ tests/test_model_catalog.py | 95 + tests/test_model_choices.py | 179 + tests/test_model_device_manager.py | 184 + tests/test_model_downloads.py | 223 + tests/test_model_folders.py | 92 + tests/test_model_instance_cache.py | 123 + tests/test_model_metadata.py | 33 + tests/test_multidiffusion_sampling.py | 783 ++ tests/test_no_external_pack_imports.py | 30 + tests/test_node_tooltips.py | 197 + tests/test_packaging_metadata.py | 124 + tests/test_prompt_composition.py | 49 + tests/test_prompt_control_availability.py | 98 + tests/test_prompt_control_batch_graph.py | 148 + tests/test_prompt_encode_style_nodes.py | 119 + ...test_prompt_segs_with_sam_compatibility.py | 110 + tests/test_prompt_segs_with_sam_node.py | 362 + tests/test_prompt_segs_with_sam_service.py | 409 + tests/test_regional_detailing_domain.py | 153 + tests/test_regional_detailing_masks.py | 210 + .../test_regional_multidiffusion_sampling.py | 941 ++ tests/test_registration.py | 531 + tests/test_resize_geometry.py | 215 + tests/test_resize_node.py | 126 + tests/test_resize_resamplers.py | 206 + tests/test_resize_service.py | 218 + tests/test_sam_loader.py | 243 + tests/test_sam_model_loader_node.py | 119 + tests/test_sam_segmenter.py | 380 + tests/test_sampling_samplers.py | 103 + tests/test_sampling_schedulers.py | 802 ++ tests/test_scale_factor_node.py | 56 + tests/test_scale_factor_v3_node.py | 37 + tests/test_seed_node.py | 44 + tests/test_segs_domain.py | 293 + tests/test_settings.py | 102 + tests/test_settings_routes.py | 188 + tests/test_simple_load_anima_node.py | 113 + tests/test_simple_load_checkpoint_node.py | 108 + tests/test_simple_load_checkpoint_v3_node.py | 102 + tests/test_simple_vae_encode_node.py | 92 + tests/test_text_box_detector.py | 310 + tests/test_third_party_vendoring_contract.py | 190 + tests/test_tile_and_tag_segs_node.py | 136 + tests/test_tile_and_tag_segs_service.py | 367 + tests/test_tile_and_tag_segs_v3_node.py | 106 + tests/test_tile_segs_domain.py | 112 + tests/test_tiled_diffusion_domain.py | 212 + .../test_tiled_diffusion_sampling_service.py | 184 + tests/test_tiled_sampling_runtime.py | 192 + tests/test_ultralytics_detection_service.py | 407 + tests/test_ultralytics_loader.py | 261 + tests/test_upscale_latent_from_image_node.py | 89 + tests/test_vae_loader.py | 196 + tests/test_vitmatte_loader.py | 309 + tests/test_vitmatte_model_loader_node.py | 126 + tests/test_vitmatte_refiner.py | 137 + tests/test_wd14_tagger_loader.py | 329 + tests/test_wd14_tagger_loader_node.py | 118 + tests/test_wd14_tagger_loader_v3_node.py | 53 + tests/test_wd14_tagger_runtime.py | 236 + third_party/NOTICE.md | 57 + .../licenses/automatic1111.LICENSE.txt | 663 ++ .../licenses/groundingdino.LICENSE.txt | 201 + third_party/licenses/k-diffusion.LICENSE.txt | 19 + .../multidiffusion-upscaler.LICENSE.txt | 437 + third_party/licenses/res4lyf.LICENSE.txt | 669 ++ third_party/licenses/sam-hq.LICENSE.txt | 201 + third_party/manifest.toml | 131 + tools/__init__.py | 5 + tools/add_license_headers.py | 199 + tsconfig.json | 15 + vitest.config.ts | 12 + web/dist/simple-syrup.js | 92 + web/src/api.ts | 63 + web/src/main.ts | 18 + web/src/settings.ts | 65 + web/src/types.ts | 38 + web/tests/api.test.ts | 66 + web/tests/settings.test.ts | 101 + web/tests/testUtils.ts | 65 + 278 files changed, 62099 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .releaserc.cjs create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 scripts/update-release-versions.mjs create mode 100644 simple_syrup/__init__.py create mode 100644 simple_syrup/domain/__init__.py create mode 100644 simple_syrup/domain/conditioning_batch.py create mode 100644 simple_syrup/domain/detail_geometry.py create mode 100644 simple_syrup/domain/graph_provenance.py create mode 100644 simple_syrup/domain/prompt_composition.py create mode 100644 simple_syrup/domain/prompt_style.py create mode 100644 simple_syrup/domain/regional_detailing.py create mode 100644 simple_syrup/domain/segs.py create mode 100644 simple_syrup/domain/tile_segs.py create mode 100644 simple_syrup/domain/tiled_diffusion.py create mode 100644 simple_syrup/image/__init__.py create mode 100644 simple_syrup/image/crop_composite.py create mode 100644 simple_syrup/image/resize_geometry.py create mode 100644 simple_syrup/image/resize_service.py create mode 100644 simple_syrup/masking/__init__.py create mode 100644 simple_syrup/masking/mask_ops.py create mode 100644 simple_syrup/masking/prompt_segs_with_sam_service.py create mode 100644 simple_syrup/masking/regional_detailing_masks.py create mode 100644 simple_syrup/masking/segs_mask_ops.py create mode 100644 simple_syrup/nodes/__init__.py create mode 100644 simple_syrup/nodes/conditioning_batch_pack.py create mode 100644 simple_syrup/nodes/detail_segs_as_regions.py create mode 100644 simple_syrup/nodes/detail_segs_by_scale_factor.py create mode 100644 simple_syrup/nodes/detail_segs_by_scale_factor_tiled_diffusion.py create mode 100644 simple_syrup/nodes/detailer_input_adapters.py create mode 100644 simple_syrup/nodes/detect_segs_with_ultralytics.py create mode 100644 simple_syrup/nodes/encode_prompt_batch.py create mode 100644 simple_syrup/nodes/grounded_sam_model_info.py create mode 100644 simple_syrup/nodes/grounding_dino_model_loader.py create mode 100644 simple_syrup/nodes/image_resize_to_target.py create mode 100644 simple_syrup/nodes/ksampler_extras.py create mode 100644 simple_syrup/nodes/ksampler_tiled_diffusion.py create mode 100644 simple_syrup/nodes/latent_diagnostics.py create mode 100644 simple_syrup/nodes/layerstyle_sam_models_adapter.py create mode 100644 simple_syrup/nodes/load_ultralytics_model.py create mode 100644 simple_syrup/nodes/prompt_encode_style.py create mode 100644 simple_syrup/nodes/prompt_encode_style_and_normalization.py create mode 100644 simple_syrup/nodes/prompt_segs_with_sam.py create mode 100644 simple_syrup/nodes/provenance_latent.py create mode 100644 simple_syrup/nodes/sam_model_loader.py create mode 100644 simple_syrup/nodes/scale_factor.py create mode 100644 simple_syrup/nodes/seed.py create mode 100644 simple_syrup/nodes/simple_load_anima.py create mode 100644 simple_syrup/nodes/simple_load_checkpoint.py create mode 100644 simple_syrup/nodes/tile_and_tag_segs.py create mode 100644 simple_syrup/nodes/tooltips.py create mode 100644 simple_syrup/nodes/vitmatte_model_loader.py create mode 100644 simple_syrup/nodes/wd14_tagger_loader.py create mode 100644 simple_syrup/nodes_v3/__init__.py create mode 100644 simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py create mode 100644 simple_syrup/nodes_v3/scale_factor.py create mode 100644 simple_syrup/nodes_v3/simple_load_checkpoint.py create mode 100644 simple_syrup/nodes_v3/tile_and_tag_segs.py create mode 100644 simple_syrup/nodes_v3/wd14_tagger_loader.py create mode 100644 simple_syrup/runtime/__init__.py create mode 100644 simple_syrup/runtime/a1111_sampling.py create mode 100644 simple_syrup/runtime/anima_loader.py create mode 100644 simple_syrup/runtime/auto_model_cache.py create mode 100644 simple_syrup/runtime/auto_model_resolver.py create mode 100644 simple_syrup/runtime/bert_resolver.py create mode 100644 simple_syrup/runtime/checkpoint_loader.py create mode 100644 simple_syrup/runtime/comfy_graph_provenance.py create mode 100644 simple_syrup/runtime/conditioning_encoding.py create mode 100644 simple_syrup/runtime/detail_previews.py create mode 100644 simple_syrup/runtime/detail_resize.py create mode 100644 simple_syrup/runtime/detail_sampling.py create mode 100644 simple_syrup/runtime/detector_compat.py create mode 100644 simple_syrup/runtime/grounding_dino_loader.py create mode 100644 simple_syrup/runtime/image_resamplers.py create mode 100644 simple_syrup/runtime/loaded_models.py create mode 100644 simple_syrup/runtime/mixture_of_diffusers_sampling.py create mode 100644 simple_syrup/runtime/model_catalog.py create mode 100644 simple_syrup/runtime/model_choices.py create mode 100644 simple_syrup/runtime/model_device_manager.py create mode 100644 simple_syrup/runtime/model_downloads.py create mode 100644 simple_syrup/runtime/model_folders.py create mode 100644 simple_syrup/runtime/model_instance_cache.py create mode 100644 simple_syrup/runtime/model_metadata.py create mode 100644 simple_syrup/runtime/multidiffusion_sampling.py create mode 100644 simple_syrup/runtime/progress.py create mode 100644 simple_syrup/runtime/prompt_control_availability.py create mode 100644 simple_syrup/runtime/prompt_control_batch_graph.py create mode 100644 simple_syrup/runtime/regional_multidiffusion_sampling.py create mode 100644 simple_syrup/runtime/sam_loader.py create mode 100644 simple_syrup/runtime/sam_segmenter.py create mode 100644 simple_syrup/runtime/sampling_samplers.py create mode 100644 simple_syrup/runtime/sampling_schedulers.py create mode 100644 simple_syrup/runtime/settings.py create mode 100644 simple_syrup/runtime/settings_routes.py create mode 100644 simple_syrup/runtime/text_box_detector.py create mode 100644 simple_syrup/runtime/tiled_sampling.py create mode 100644 simple_syrup/runtime/torchlanc_resampler.py create mode 100644 simple_syrup/runtime/ultralytics_detection.py create mode 100644 simple_syrup/runtime/ultralytics_loader.py create mode 100644 simple_syrup/runtime/vae_loader.py create mode 100644 simple_syrup/runtime/vitmatte_loader.py create mode 100644 simple_syrup/runtime/vitmatte_refiner.py create mode 100644 simple_syrup/runtime/wd14_tagger.py create mode 100644 simple_syrup/runtime/wd14_tagger_loader.py create mode 100644 simple_syrup/services/__init__.py create mode 100644 simple_syrup/services/detail_segs_as_regions_service.py create mode 100644 simple_syrup/services/detail_segs_by_scale_factor_service.py create mode 100644 simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py create mode 100644 simple_syrup/services/latent_diagnostics_service.py create mode 100644 simple_syrup/services/segs_detection_service.py create mode 100644 simple_syrup/services/segs_output_service.py create mode 100644 simple_syrup/services/tile_and_tag_segs_service.py create mode 100644 simple_syrup/services/tiled_diffusion_sampling_service.py create mode 100644 simple_syrup/shared/__init__.py create mode 100644 simple_syrup/shared/logging.py create mode 100644 simple_syrup/shared/tensor_validation.py create mode 100644 simple_syrup/third_party/__init__.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/__init__.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/datasets/__init__.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/datasets/transforms.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/__init__.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/__init__.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/backbone.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/position_encoding.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/swin_transformer.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/bertwarper.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/fuse_modules.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/groundingdino.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/ms_deform_attn.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer_vanilla.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/utils.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/__init__.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/registry.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/util/__init__.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/util/box_ops.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/util/get_tokenlizer.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/util/inference.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/util/misc.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/util/slconfig.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/util/slio.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/util/utils.py create mode 100644 simple_syrup/third_party/sam_hq_runtime/__init__.py create mode 100644 simple_syrup/third_party/sam_hq_runtime/automatic.py create mode 100644 simple_syrup/third_party/sam_hq_runtime/build_sam_hq.py create mode 100644 simple_syrup/third_party/sam_hq_runtime/modeling/__init__.py create mode 100644 simple_syrup/third_party/sam_hq_runtime/modeling/image_encoder.py create mode 100644 simple_syrup/third_party/sam_hq_runtime/modeling/mask_decoder_hq.py create mode 100644 simple_syrup/third_party/sam_hq_runtime/modeling/tiny_vit.py create mode 100644 simple_syrup/third_party/sam_hq_runtime/predictor.py create mode 100644 test_helpers.py create mode 100644 tests/conftest.py create mode 100644 tests/test_a1111_sampling.py create mode 100644 tests/test_anima_loader.py create mode 100644 tests/test_auto_model_cache.py create mode 100644 tests/test_auto_model_resolver.py create mode 100644 tests/test_bert_resolver.py create mode 100644 tests/test_checkpoint_loader.py create mode 100644 tests/test_conditioning_batch.py create mode 100644 tests/test_conditioning_batch_pack_node.py create mode 100644 tests/test_detail_geometry.py create mode 100644 tests/test_detail_previews.py create mode 100644 tests/test_detail_resize.py create mode 100644 tests/test_detail_segs_as_regions_node.py create mode 100644 tests/test_detail_segs_as_regions_service.py create mode 100644 tests/test_detail_segs_by_scale_factor_node.py create mode 100644 tests/test_detail_segs_by_scale_factor_service.py create mode 100644 tests/test_detail_segs_by_scale_factor_tiled_diffusion_node.py create mode 100644 tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py create mode 100644 tests/test_detect_segs_with_ultralytics_node.py create mode 100644 tests/test_detector_compat.py create mode 100644 tests/test_encode_prompt_batch_node.py create mode 100644 tests/test_encode_prompt_batch_with_prompt_control_node.py create mode 100644 tests/test_graph_provenance.py create mode 100644 tests/test_grounded_sam_model_info_node.py create mode 100644 tests/test_grounding_dino_loader.py create mode 100644 tests/test_grounding_dino_model_loader_node.py create mode 100644 tests/test_ksampler_extras_node.py create mode 100644 tests/test_ksampler_tiled_diffusion_node.py create mode 100644 tests/test_latent_diagnostics_node.py create mode 100644 tests/test_latent_diagnostics_service.py create mode 100644 tests/test_layerstyle_sam_models_adapter_node.py create mode 100644 tests/test_license_headers.py create mode 100644 tests/test_load_ultralytics_model_node.py create mode 100644 tests/test_loaded_models.py create mode 100644 tests/test_mask_ops.py create mode 100644 tests/test_mixture_of_diffusers_sampling.py create mode 100644 tests/test_model_catalog.py create mode 100644 tests/test_model_choices.py create mode 100644 tests/test_model_device_manager.py create mode 100644 tests/test_model_downloads.py create mode 100644 tests/test_model_folders.py create mode 100644 tests/test_model_instance_cache.py create mode 100644 tests/test_model_metadata.py create mode 100644 tests/test_multidiffusion_sampling.py create mode 100644 tests/test_no_external_pack_imports.py create mode 100644 tests/test_node_tooltips.py create mode 100644 tests/test_packaging_metadata.py create mode 100644 tests/test_prompt_composition.py create mode 100644 tests/test_prompt_control_availability.py create mode 100644 tests/test_prompt_control_batch_graph.py create mode 100644 tests/test_prompt_encode_style_nodes.py create mode 100644 tests/test_prompt_segs_with_sam_compatibility.py create mode 100644 tests/test_prompt_segs_with_sam_node.py create mode 100644 tests/test_prompt_segs_with_sam_service.py create mode 100644 tests/test_regional_detailing_domain.py create mode 100644 tests/test_regional_detailing_masks.py create mode 100644 tests/test_regional_multidiffusion_sampling.py create mode 100644 tests/test_registration.py create mode 100644 tests/test_resize_geometry.py create mode 100644 tests/test_resize_node.py create mode 100644 tests/test_resize_resamplers.py create mode 100644 tests/test_resize_service.py create mode 100644 tests/test_sam_loader.py create mode 100644 tests/test_sam_model_loader_node.py create mode 100644 tests/test_sam_segmenter.py create mode 100644 tests/test_sampling_samplers.py create mode 100644 tests/test_sampling_schedulers.py create mode 100644 tests/test_scale_factor_node.py create mode 100644 tests/test_scale_factor_v3_node.py create mode 100644 tests/test_seed_node.py create mode 100644 tests/test_segs_domain.py create mode 100644 tests/test_settings.py create mode 100644 tests/test_settings_routes.py create mode 100644 tests/test_simple_load_anima_node.py create mode 100644 tests/test_simple_load_checkpoint_node.py create mode 100644 tests/test_simple_load_checkpoint_v3_node.py create mode 100644 tests/test_simple_vae_encode_node.py create mode 100644 tests/test_text_box_detector.py create mode 100644 tests/test_third_party_vendoring_contract.py create mode 100644 tests/test_tile_and_tag_segs_node.py create mode 100644 tests/test_tile_and_tag_segs_service.py create mode 100644 tests/test_tile_and_tag_segs_v3_node.py create mode 100644 tests/test_tile_segs_domain.py create mode 100644 tests/test_tiled_diffusion_domain.py create mode 100644 tests/test_tiled_diffusion_sampling_service.py create mode 100644 tests/test_tiled_sampling_runtime.py create mode 100644 tests/test_ultralytics_detection_service.py create mode 100644 tests/test_ultralytics_loader.py create mode 100644 tests/test_upscale_latent_from_image_node.py create mode 100644 tests/test_vae_loader.py create mode 100644 tests/test_vitmatte_loader.py create mode 100644 tests/test_vitmatte_model_loader_node.py create mode 100644 tests/test_vitmatte_refiner.py create mode 100644 tests/test_wd14_tagger_loader.py create mode 100644 tests/test_wd14_tagger_loader_node.py create mode 100644 tests/test_wd14_tagger_loader_v3_node.py create mode 100644 tests/test_wd14_tagger_runtime.py create mode 100644 third_party/NOTICE.md create mode 100644 third_party/licenses/automatic1111.LICENSE.txt create mode 100644 third_party/licenses/groundingdino.LICENSE.txt create mode 100644 third_party/licenses/k-diffusion.LICENSE.txt create mode 100644 third_party/licenses/multidiffusion-upscaler.LICENSE.txt create mode 100644 third_party/licenses/res4lyf.LICENSE.txt create mode 100644 third_party/licenses/sam-hq.LICENSE.txt create mode 100644 third_party/manifest.toml create mode 100644 tools/__init__.py create mode 100644 tools/add_license_headers.py create mode 100644 tsconfig.json create mode 100644 vitest.config.ts create mode 100644 web/dist/simple-syrup.js create mode 100644 web/src/api.ts create mode 100644 web/src/main.ts create mode 100644 web/src/settings.ts create mode 100644 web/src/types.ts create mode 100644 web/tests/api.test.ts create mode 100644 web/tests/settings.test.ts create mode 100644 web/tests/testUtils.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e7764e1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,107 @@ +name: release + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + dry_run: + description: "Run semantic-release in dry-run mode without publishing" + required: false + default: "false" + type: choice + options: + - "true" + - "false" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + env: + SIMPLE_SYRUP_TEST_COMFY_CPU: "1" + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - 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: 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 + + - name: Verify frontend + run: npm run check:web + + - name: Release + id: release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + log_file="$(mktemp)" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.dry_run }}" = "true" ]; then + npx semantic-release --dry-run --no-ci 2>&1 | tee "$log_file" + echo "released=false" >> "$GITHUB_OUTPUT" + else + npx semantic-release 2>&1 | tee "$log_file" + if grep -Eq "Published release |Created tag v[0-9]" "$log_file"; then + echo "released=true" >> "$GITHUB_OUTPUT" + else + echo "released=false" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Sync workspace to released commit + if: steps.release.outputs.released == 'true' + shell: bash + run: | + set -euo pipefail + git fetch origin main --tags + git checkout main + git reset --hard origin/main + + - name: Publish Custom Node to Comfy Registry + if: steps.release.outputs.released == 'true' + uses: Comfy-Org/publish-node-action@main + with: + skip_checkout: true + personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f84f6e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +node_modules/ +npm-debug.log* diff --git a/.releaserc.cjs b/.releaserc.cjs new file mode 100644 index 0000000..637ad17 --- /dev/null +++ b/.releaserc.cjs @@ -0,0 +1,33 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +module.exports = { + branches: ["main"], + tagFormat: "v${version}", + plugins: [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + ["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }], + [ + "@semantic-release/exec", + { + prepareCmd: "node scripts/update-release-versions.mjs ${nextRelease.version}", + }, + ], + [ + "@semantic-release/git", + { + assets: [ + "package.json", + "package-lock.json", + "pyproject.toml", + "simple_syrup/__init__.py", + "CHANGELOG.md", + ], + message: + "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", + }, + ], + ], +}; diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e182125 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,316 @@ +# AGENTS.md + +## Mission Statement + +This project provides a high-quality Python ComfyUI custom node extension pack. + +Engineering priority is strict architecture, strong separation of concerns, complete feature integrations, behavior safety during structural change, deterministic node behavior, explicit validation, ComfyUI compatibility, runtime safety, and long-term maintainability. + +## Purpose + +- This file defines engineering guardrails for this repository. +- This file governs architecture, code quality, typing, testing, observability, runtime safety, ComfyUI node behavior, workflow behavior, and extension packaging. +- Do not use this file for feature specs or product planning. + +## Behavior Boundary + +- Preserve existing user-facing behavior unless explicitly approved to change. +- Preserve compatibility for supported ComfyUI node classes, node names, input definitions, output definitions, categories, return types, and execution behavior unless explicitly approved to change. +- Preserve compatibility for serialized ComfyUI workflows that reference this node pack unless explicitly approved to change. +- Treat current node signatures, widget names, return shapes, validation behavior, and workflow-facing identifiers as the contract. +- Change internals freely within that boundary. + +## Environment and Gate Execution + +- All verification commands must run against the ComfyUI virtual environment located two directories above this repository. +- From this repository root, the required Python environment is `..\..\venv`. +- Do not create or use a repository-local `.venv`. +- Do not run quality gates with global/system Python. +- If the ComfyUI virtual environment is missing or stale, pause and ask for maintainer direction before recreating it. +- If development tools are missing from the ComfyUI virtual environment, install or update this node pack and its development tooling into that environment. +- Run all commands from the repository root. +- Use PowerShell syntax and Windows paths. +- The ComfyUI virtual environment is authoritative because node behavior may depend on the exact installed ComfyUI, PyTorch, image, tensor, and custom node runtime dependencies available to the host application. + +### Required Command Forms + +- Tests: `..\..\venv\Scripts\python.exe -m pytest -n auto -q` +- Lint: `..\..\venv\Scripts\ruff.exe check .` +- Format: `..\..\venv\Scripts\ruff.exe format .` +- Type check: `..\..\venv\Scripts\mypy.exe --strict simple_syrup tests` + +If a required tool is missing from `..\..\venv`, install or update development dependencies in that environment before verification. Do not substitute global tools. + +## Core Engineering Principles + +- Use strict object-oriented design where ownership, state, lifecycle, or collaboration boundaries exist. +- Enforce strong separation of concerns as the primary architecture objective. +- Keep modules cohesive and boundaries explicit. +- Assign one authoritative owner per concern. +- Other components may participate in a concern only by using the authoritative owner. They must not re-implement that concern in parallel. +- Reassess ownership before extending an existing structure. +- If a change introduces a distinct responsibility, change cadence, or collaboration boundary, split or extract it as part of the change. +- Complete refactors fully. Update all callsites, remove dead code, remove temporary bridges, and make the new design native to the codebase. +- Complete feature additions fully. Wire the feature through the relevant node class, service layer, validation path, tests, typing, and ComfyUI registration path required by the behavior. +- Do not leave partial implementations, unused code paths, TODO-driven behavior, or follow-up cleanup inside the completed change. +- Do not add internal compatibility layers, internal shims, dual internal paths, legacy fallbacks, or transitional adapters. +- Preserve compatibility only at public ComfyUI-facing or persisted workflow boundaries when required by the behavior contract. +- Favor DRY when it reduces repeated change risk. +- Avoid abstractions that hide intent. + +## 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. +- 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. +- Shared layer: small cross-layer primitives with no higher-level dependencies. +- Higher-level layers may depend on lower-level layers. +- Lower-level layers must not depend on higher-level layers. +- ComfyUI registration must stay thin and must not contain feature logic. +- Node classes must delegate non-trivial behavior to application services or domain objects. +- Domain logic must not import ComfyUI modules. +- Runtime adapters own external system interaction. +- Keep filesystem, subprocess, and network details out of node classes and domain logic. +- Place code by ownership and dependency direction, not convenience or proximity. +- Avoid god classes and monolithic files. +- Split by responsibility, not convenience. + +## Structural Change Rules + +- 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. +- 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. +- Land structural changes as complete vertical slices. +- Do not land large unverified rewrites. +- If behavior changes are intentional, explicitly call them out and test them as new behavior. +- Current module layout does not constrain improvement. +- Reorganize modules when it improves architecture. +- Align touched modules with the ownership and dependency rules in this file. + +## 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. +- 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. +- Optional behavior must be explicit in inputs and tested. +- Hidden inputs and ComfyUI-specific metadata must be documented in code through clear names and docstrings. +- Any workflow-affecting default value change is a behavior change and requires tests. +- Errors surfaced to ComfyUI users must be actionable. + +## Tooltip Standards + +- Every ComfyUI-facing node must provide a concise, user-facing `DESCRIPTION`. +- Every visible node input, optional input, hidden input, and output tooltip supported by the API must have tooltip text. +- Tooltips should help a workflow builder make the right choice without reading code. +- Tooltips must be concise, layman-friendly, and technically accurate enough to explain the practical effect of the setting. +- Prefer one sentence. Use two only when the field has an important tradeoff or safety implication. +- Explain what the control does, what changing it affects, and any important tradeoff. +- Include units when relevant, such as pixels, latent pixels, scale multiplier, mask value, batch count, or strength. +- Explain directionality when useful: higher/lower, enabled/disabled, larger/smaller. +- 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. + +## 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: + - `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. + +## Code Organization and Readability + +- Write self-documenting code with expressive, concise names. +- Place new code deliberately in the module where it naturally belongs. +- Keep files intentionally organized so reading order reflects design intent. +- Do not place code opportunistically "where it works". +- Remove obsolete code paths when replacements are complete. +- Keep ComfyUI registration concerns at the package boundary. +- Keep node declaration concerns in node modules. +- Keep reusable behavior in services or domain modules. +- Keep filesystem, network, subprocess, and external-library adaptation in runtime adapter modules. + +## Docstrings and Comments + +- Docstrings are mandatory for all new and changed modules, classes, functions, and methods. +- Use concise imperative docstrings for simple logic. +- Use Google-style docstrings for complex logic. +- Docstrings must explain rationale, constraints, and intent. +- Docstrings must not restate obvious mechanics. +- Inline comments are allowed only for non-obvious behavior, invariants, edge cases, or external constraints. + +## Documentation Policy + +- Do not create new docs files, README variants, design docs, ADRs, roadmap files, or notes unless explicitly requested by the maintainer. +- Required context must live in code, type hints, tests, and docstrings. +- Documentation and explanatory writing must describe the product directly as it exists now. +- Do not document against removed features, imagined alternatives, or non-existent choices. + +## Typing Policy + +- Strong typing is required for all new code. +- Modified code must be typed as part of the change. +- Type hints are mandatory on function signatures and key internal state. +- Use explicit domain types, dataclasses, TypedDicts, Protocols, and type narrowing instead of `Any`. +- `Any` is allowed only at ComfyUI, JSON, tensor, or dynamic plugin boundaries and must be narrowed before core logic relies on it. +- Run `mypy --strict` for type verification. +- Temporary typing relaxations are allowed only when explicitly justified inline and tracked for removal. + +## Logging, Errors, and Observability + +- Observability is mandatory. +- Use structured, actionable logging with context identifiers where relevant. +- Include enough context to diagnose failures quickly, such as node class, display name, input name, output name, file path, model name, operation, and ComfyUI workflow-facing identifier. +- Use log levels consistently: `debug`, `info`, `warning`, `error`. +- Preserve exception context and stack traces for unexpected failures. +- `print` is not allowed for runtime diagnostics. +- Bare `except:` is not allowed. +- `except Exception` must be narrow, intentional, and log context plus failure reason. +- Silent exception swallowing is not allowed. +- Errors exposed from node, service, validation, and runtime boundaries must be explicit and actionable. + +## Desktop Security and Safety Rules + +- Treat workflow inputs, filesystem paths, model paths, image/audio paths, generated data, subprocess execution, and network access as security-sensitive. +- Never execute untrusted code paths from workflows, metadata, file contents, or generated data. +- Validate and sanitize external paths and user-provided file references. +- Use structured parsing and validation for JSON, metadata, and configuration. +- Use subprocess argument lists, never shell-string execution. +- Set explicit timeouts for network operations. +- Fail closed when trust, schema validation, path validation, or version validation is uncertain. +- Never log secrets, tokens, credentials, or sensitive local paths beyond what is necessary for diagnosis. +- Do not silently continue after invalid node inputs, missing files, invalid model references, failed conversions, malformed metadata, or unsupported data shapes. + +## Testing Policy + +- Add or update tests for every behavior change and every bug fix. +- Add characterization tests before structural changes to behavior-critical areas. +- New behavior must not be unverified. +- Include success and failure path coverage. +- Include regression tests for fixed bugs. +- Keep tests deterministic and isolated. +- 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. +- 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. +- Add or update frontend tests for frontend behavior changes. +- Settings UI changes require tests for registration, default value, persistence call, and failure handling. +- Backend/frontend settings bridges require Python tests for routes and persistence plus TypeScript tests for API calls. + +## Test Execution Rules + +- Run tests in parallel using xdist. +- Default command: `..\..\venv\Scripts\python.exe -m pytest -n auto -q`. +- If running a focused subset during development, run the full suite before completion. +- Failing tests are blocking. + +## Python Toolchain + +- Formatter: `ruff format` +- Linter: `ruff check` +- Type checker: `mypy --strict` +- Test runner: `pytest -n auto -q` + +## TypeScript / Frontend Toolchain + +- Frontend code lives under `web/src`. +- Frontend tests live under `web/tests`. +- Built browser artifacts live under `web/dist`. +- Do not hand-edit generated files in `web/dist`; edit `web/src` and run the build. +- Use TypeScript for ComfyUI frontend extensions. +- Do not add untyped JavaScript for new frontend behavior unless it is generated build output. +- Keep frontend code small, typed, and framework-free unless explicitly approved. +- Frontend code must not own backend policy. It may display and edit settings, but Python runtime settings remain the source of truth. +- If `package.json` exists, run these from repository root: + +```powershell +npm ci +npm run lint:web +npm run typecheck:web +npm run test:web +npm run build:web +``` + +- Do not use global TypeScript, ESLint, or Vitest directly. + +## ComfyUI Frontend Rules + +- ComfyUI frontend extensions must be registered from TypeScript source under `web/src`. +- 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. +- Backend code must validate settings read from disk or HTTP. +- Frontend code must handle backend route failures visibly and conservatively. +- Do not perform model discovery, filesystem assumptions, or download decisions in frontend code. +- Do not duplicate model-listing policy in frontend code; call backend APIs or edit backend settings only. + +## Verification Workflow + +- Run focused checks continuously while implementing. +- Verify the specific reported behavior directly when feasible. +- Do not declare a node, workflow, runtime, validation, or packaging issue fixed from code inspection alone when a direct test is feasible. +- Run full gates before reporting completion. +- Distinguish observed results from inferred results in updates and completion reports. +- Do not introduce new lint/type failures in modified files. +- Do not report completion if any blocking gate fails. +- If a gate is intentionally deferred, explicitly state the reason and risk. + +## Definition of Done + +Per change, all of the following are required: + +- Behavior is safeguarded by tests. +- New/modified code follows architecture boundaries. +- New/modified code placement reflects ownership and dependency rules in this file. +- Refactors are complete, with callsites updated and obsolete internal paths removed. +- Features are complete, with node registration, node API, services, runtime adapters, validation, typing, and tests updated wherever the behavior requires them. +- New/modified code is typed. +- Required docstrings are present and meaningful. +- Logging/error handling is actionable. +- Security-sensitive boundaries validate inputs and fail closed. +- `..\..\venv\Scripts\ruff.exe format .` passes. +- `..\..\venv\Scripts\ruff.exe check .` passes. +- `..\..\venv\Scripts\mypy.exe --strict simple_syrup tests` passes for the enforced scope. +- `..\..\venv\Scripts\python.exe -m pytest -n auto -q` passes. +- 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. + +## Commit Policy + +- Use Conventional Commits: `type(scope): subject`. +- Allowed types: `feat`, `fix`, `refactor`, `test`, `chore`, `docs`, `build`, `ci`. +- Keep commits atomic and cohesive. +- Breaking structural changes must be clearly labeled. + +## Maintainer Authority + +- Maintainer instructions override this file. +- If constraints conflict, pause and ask for maintainer direction before proceeding. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6361e43 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to this project will be documented in this file. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa3dadf --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# SimpleSyrup + +**SimpleSyrup** is a ComfyUI node pack that grew out of moving my A1111/WebUI image workflows into ComfyUI graphs. + +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 pulls from a few different places: + +- 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. + +## 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. + +## Installation + +**Recommended: install through ComfyUI Manager** + +Open **Manager** from the ComfyUI toolbar, click **Custom Nodes Manager**, search for **SimpleSyrup**, and click **Install**. Restart ComfyUI after installation. + +**Manual install** + +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. + +```powershell +cd ComfyUI\custom_nodes +git clone https://github.com/Artificial-Sweetener/SimpleSyrup.git +cd SimpleSyrup +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. + +## The Nodes + +SimpleSyrup is organized around workflow jobs, not socket types. + +### Impact-Style SEGS Workflows + +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. + +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. + +- **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. + +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. + +### Per-Segment Prompt Batches + +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]`. + +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. + +- **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. + +### Tile, Tag, and Guide + +**Tile & Tag SEGS** is for workflows where tile regions should carry their own generated prompt guidance. + +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. + +That is the kind of thing that is easy to do once by hand and annoying to keep correct in a real graph. + +### Tiled Sampling + +**KSampler (Tiled Diffusion)** is a KSampler-style node with selectable **MultiDiffusion** and **Mixture of Diffusers** modes. + +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. + +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. + +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. + +### Diffusion Loaders + +**Simple Load Checkpoint** is meant to feel familiar if you come from WebUI, where the common generation controls live near the model selection. + +- **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. + +### Model and Detector Loaders + +These nodes load the models used by detection, segmentation, tagging, matting, and compatibility workflows. + +- **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. + +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. + +### Sampler and Scheduler Extras + +**KSampler (Extras)** keeps the normal Comfy sampler shape, but adds sampler and scheduler behavior I wanted available without dragging in a separate sampler stack. + +It includes: + +- `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. + +The node still uses Comfy-style seed handling, partial denoise behavior, progress callbacks, and normal positive/negative conditioning inputs. + +### Image, Prompt, and Latent Utilities + +These nodes handle the smaller jobs that show up all over image workflows. + +- **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 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. + +## Settings + +SimpleSyrup adds one ComfyUI setting: + +- **SimpleSyrup: Show downloadable models in loader dropdowns** + +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. + +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. + +## License & Acknowledgements + +**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. + +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. + +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. + +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. + +### Research Citations + +SimpleSyrup's tiled diffusion behavior is based on ideas from MultiDiffusion and Mixture of Diffusers. + +```bibtex +@article{bar2023multidiffusion, + title={MultiDiffusion: Fusing Diffusion Paths for Controlled Image Generation}, + author={Bar-Tal, Omer and Yariv, Lior and Lipman, Yaron and Dekel, Tali}, + journal={arXiv preprint arXiv:2302.08113}, + year={2023} +} +``` + +```bibtex +@article{barbero2023mixture, + title={Mixture of Diffusers for scene composition and high resolution image generation}, + author={Barbero Jimenez, Alvaro}, + journal={arXiv preprint arXiv:2302.02412}, + year={2023} +} +``` + +## 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). +- **If you like this project**, it would mean a lot to me if you gave me a star here on GitHub!! ⭐ diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..940bbd1 --- /dev/null +++ b/__init__.py @@ -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 + +"""SimpleSyrup ComfyUI extension entry point.""" + +from __future__ import annotations + +import sys + +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.runtime.settings_routes import register_settings_routes # noqa: E402 + +WEB_DIRECTORY = "./web/dist" + + +async def comfy_entrypoint() -> object: + """Return Comfy v3 extension nodes without importing optional integrations.""" + + from comfy_api.latest import ComfyExtension + + class SimpleSyrupExtension(ComfyExtension): + """Expose SimpleSyrup's Comfy v3 nodes.""" + + async def get_node_list(self) -> list[type[object]]: + """Return v3 nodes after Comfy asks for them.""" + + from .simple_syrup.nodes_v3 import get_nodes + + return get_nodes() + + return SimpleSyrupExtension() + + +register_settings_routes() + +__all__ = [ + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", + "WEB_DIRECTORY", + "comfy_entrypoint", +] diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f68c902 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,22 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.strictTypeChecked, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname + } + }, + rules: { + "@typescript-eslint/consistent-type-imports": "error" + } + } +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cf9ddcd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9516 @@ +{ + "name": "simple-syrup-comfyui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simple-syrup-comfyui", + "version": "0.1.0", + "license": "AGPL-3.0-or-later", + "devDependencies": { + "@eslint/js": "^9.39.1", + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/exec": "^7.1.0", + "@semantic-release/git": "^10.0.1", + "@semantic-release/release-notes-generator": "^14.1.1", + "@types/node": "^25.6.0", + "esbuild": "^0.27.0", + "eslint": "^9.39.1", + "jsdom": "^27.2.0", + "semantic-release": "^25.0.3", + "typescript": "^5.9.3", + "typescript-eslint": "^8.46.4", + "vitest": "^4.0.8" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@actions/core": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", + "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-retry": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", + "integrity": "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=7" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", + "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": "^7.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.9", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.9.tgz", + "integrity": "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "content-type": "^2.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", + "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@semantic-release/changelog": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-6.0.3.tgz", + "integrity": "sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/error": "^3.0.0", + "aggregate-error": "^3.0.0", + "fs-extra": "^11.0.0", + "lodash": "^4.17.4" + }, + "engines": { + "node": ">=14.17" + }, + "peerDependencies": { + "semantic-release": ">=18.0.0" + } + }, + "node_modules/@semantic-release/commit-analyzer": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz", + "integrity": "sha512-wdnBPHKkr9HhNhXOhZD5a2LNl91+hs8CC2vsAVYxtZH3y0dV3wKn+uZSN61rdJQZ8EGxzWB3inWocBHV9+u/CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/error": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", + "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@semantic-release/exec": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/exec/-/exec-7.1.0.tgz", + "integrity": "sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^3.0.0", + "debug": "^4.0.0", + "execa": "^9.0.0", + "lodash-es": "^4.17.21", + "parse-json": "^8.0.0" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=24.1.0" + } + }, + "node_modules/@semantic-release/exec/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/git": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz", + "integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/error": "^3.0.0", + "aggregate-error": "^3.0.0", + "debug": "^4.0.0", + "dir-glob": "^3.0.0", + "execa": "^5.0.0", + "lodash": "^4.17.4", + "micromatch": "^4.0.0", + "p-reduce": "^2.0.0" + }, + "engines": { + "node": ">=14.17" + }, + "peerDependencies": { + "semantic-release": ">=18.0.0" + } + }, + "node_modules/@semantic-release/git/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@semantic-release/git/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/git/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@semantic-release/git/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/git/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/git/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@semantic-release/git/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@semantic-release/github": { + "version": "12.0.8", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.8.tgz", + "integrity": "sha512-tej5AAgK5X9wHRoDmYhecMXEHEkFeGOY1XsEblKxu8pIQwahzf1STYyr7iPU6Lpbg6C5I3N2w/ocXrBo+L7jhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.0", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-retry": "^8.0.0", + "@octokit/plugin-throttling": "^11.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.3.4", + "dir-glob": "^3.0.1", + "http-proxy-agent": "^9.0.0", + "https-proxy-agent": "^9.0.0", + "issue-parser": "^7.0.0", + "lodash-es": "^4.17.21", + "mime": "^4.0.0", + "p-filter": "^4.0.0", + "tinyglobby": "^0.2.14", + "undici": "^7.0.0", + "url-join": "^5.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=24.1.0" + } + }, + "node_modules/@semantic-release/github/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/github/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@semantic-release/github/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/http-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", + "integrity": "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@semantic-release/github/node_modules/https-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", + "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@semantic-release/github/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm": { + "version": "13.1.5", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz", + "integrity": "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/core": "^3.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "env-ci": "^11.2.0", + "execa": "^9.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^9.0.0", + "npm": "^11.6.2", + "rc": "^1.2.8", + "read-pkg": "^10.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/release-notes-generator": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "read-package-up": "^11.0.0" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/argv-formatter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/argv-formatter/-/argv-formatter-1.0.0.tgz", + "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cli-highlight/node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-highlight/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-highlight/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-changelog-writer": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.4.0.tgz", + "integrity": "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "conventional-commits-filter": "^5.0.0", + "handlebars": "^4.7.7", + "meow": "^13.0.0", + "semver": "^7.5.2" + }, + "bin": { + "conventional-changelog-writer": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-ci": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", + "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^8.0.0", + "java-properties": "^1.0.2" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/env-ci/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/env-ci/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/env-ci/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-versions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", + "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver-regex": "^4.0.5", + "super-regex": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-log-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.1.tgz", + "integrity": "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "argv-formatter": "~1.0.0", + "spawn-error-forwarder": "~1.0.0", + "split2": "~1.0.0", + "stream-combiner2": "~1.1.1", + "through2": "~2.0.0", + "traverse": "0.6.8" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hook-std": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", + "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + }, + "engines": { + "node": ">=18.20" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/issue-parser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.2.tgz", + "integrity": "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/java-properties": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", + "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.capitalize": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", + "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/marked-terminal": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", + "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "marked": ">=1 <16" + } + }, + "node_modules/marked-terminal/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nerf-dart": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", + "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-url": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz", + "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.15.0.tgz", + "integrity": "sha512-+k0tk7lRnpMUPnC7kTuU/yrV/mnFoPhJQ75VfLtZ6fwbzOVXaPsTE/Il9Pn1DHi482byMyqkHv/XsQ76mNjXLw==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], + "dev": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.6.0", + "@npmcli/config": "^10.9.1", + "@npmcli/fs": "^5.0.0", + "@npmcli/map-workspaces": "^5.0.3", + "@npmcli/metavuln-calculator": "^9.0.3", + "@npmcli/package-json": "^7.0.5", + "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.4", + "@sigstore/tuf": "^4.0.2", + "abbrev": "^4.0.0", + "archy": "~1.0.0", + "cacache": "^20.0.4", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^9.0.3", + "ini": "^6.0.0", + "init-package-json": "^8.2.5", + "is-cidr": "^6.0.4", + "json-parse-even-better-errors": "^5.0.0", + "libnpmaccess": "^10.0.3", + "libnpmdiff": "^8.1.8", + "libnpmexec": "^10.2.8", + "libnpmfund": "^7.0.22", + "libnpmorg": "^8.0.1", + "libnpmpack": "^9.1.8", + "libnpmpublish": "^11.2.0", + "libnpmsearch": "^9.0.1", + "libnpmteam": "^8.0.2", + "libnpmversion": "^8.0.3", + "make-fetch-happen": "^15.0.5", + "minimatch": "^10.2.5", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^12.3.0", + "nopt": "^9.0.0", + "npm-audit-report": "^7.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.2", + "npm-pick-manifest": "^11.0.3", + "npm-profile": "^12.0.1", + "npm-registry-fetch": "^19.1.1", + "npm-user-validate": "^4.0.0", + "p-map": "^7.0.4", + "pacote": "^21.5.0", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.1.0", + "qrcode-terminal": "^0.12.0", + "read": "^5.0.1", + "semver": "^7.8.0", + "spdx-expression-parse": "^4.0.0", + "ssri": "^13.0.1", + "supports-color": "^10.2.2", + "tar": "^7.5.15", + "text-table": "~0.2.0", + "tiny-relative-date": "^2.0.2", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/@gar/promise-retry": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "9.6.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "10.9.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "7.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "10.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@sigstore/bundle": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@sigstore/core": { + "version": "3.2.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/sign": { + "version": "4.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "4.0.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@sigstore/verify": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/models": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/npm/node_modules/bin-links": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "5.0.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "20.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "5.0.5", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/npm/node_modules/cmd-shim": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/diff": { + "version": "8.0.4", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "9.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.7.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/npm/node_modules/ignore-walk": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/ini": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "8.2.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "npm-package-arg": "^13.0.0", + "promzard": "^3.0.1", + "read": "^5.0.1", + "semver": "^7.7.2", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "10.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "6.0.4", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^5.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/npm/node_modules/isexe": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "10.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "8.1.8", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.6.0", + "@npmcli/installed-package-contents": "^4.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "tar": "^7.5.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "10.2.8", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^9.6.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "proc-log": "^6.0.0", + "read": "^5.0.1", + "semver": "^7.3.7", + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "7.0.22", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.6.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "8.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "9.1.8", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.6.0", + "@npmcli/run-script": "^10.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "11.2.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7", + "sigstore": "^4.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "8.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "11.5.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "15.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "5.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^7.1.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "12.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-bundled": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "13.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "10.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "11.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "12.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "19.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "7.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/pacote": { + "version": "21.5.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "6.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/proggy": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^3.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.8.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.9", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.23", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/ssri": { + "version": "13.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "10.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "7.5.15", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/undici": { + "version": "6.25.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/which": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "7.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-each-series": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", + "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-filter": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", + "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-map": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-reduce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", + "integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", + "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^2.0.0", + "load-json-file": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semantic-release": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", + "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/error": "^4.0.0", + "@semantic-release/github": "^12.0.0", + "@semantic-release/npm": "^13.1.1", + "@semantic-release/release-notes-generator": "^14.1.0", + "aggregate-error": "^5.0.0", + "cosmiconfig": "^9.0.0", + "debug": "^4.0.0", + "env-ci": "^11.0.0", + "execa": "^9.0.0", + "figures": "^6.0.0", + "find-versions": "^6.0.0", + "get-stream": "^6.0.0", + "git-log-parser": "^1.2.0", + "hook-std": "^4.0.0", + "hosted-git-info": "^9.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "marked": "^15.0.0", + "marked-terminal": "^7.3.0", + "micromatch": "^4.0.2", + "p-each-series": "^3.0.0", + "p-reduce": "^3.0.0", + "read-package-up": "^12.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.2", + "signale": "^1.2.1", + "yargs": "^18.0.0" + }, + "bin": { + "semantic-release": "bin/semantic-release.js" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + } + }, + "node_modules/semantic-release/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/semantic-release/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/p-reduce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", + "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/read-package-up": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semantic-release/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-regex": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", + "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/signale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz", + "integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.3.2", + "figures": "^2.0.0", + "pkg-conf": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/signale/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/signale/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/signale/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/signale/node_modules/figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-error-forwarder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", + "integrity": "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", + "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", + "dev": true, + "license": "ISC", + "dependencies": { + "through2": "~2.0.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tempy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz", + "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/traverse": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", + "integrity": "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd63729 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "simple-syrup-comfyui", + "version": "0.1.0", + "private": true, + "license": "AGPL-3.0-or-later", + "type": "module", + "scripts": { + "lint:web": "eslint web/src web/tests", + "typecheck:web": "tsc --noEmit", + "test:web": "vitest run", + "build:web": "esbuild web/src/main.ts --bundle --format=esm --target=es2022 --outfile=web/dist/simple-syrup.js --external:../../../scripts/app.js", + "check:web": "npm run lint:web && npm run typecheck:web && npm run test:web && npm run build:web", + "release": "semantic-release" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/exec": "^7.1.0", + "@semantic-release/git": "^10.0.1", + "@semantic-release/release-notes-generator": "^14.1.1", + "@types/node": "^25.6.0", + "esbuild": "^0.27.0", + "eslint": "^9.39.1", + "jsdom": "^27.2.0", + "semantic-release": "^25.0.3", + "typescript": "^5.9.3", + "typescript-eslint": "^8.46.4", + "vitest": "^4.0.8" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b6f9234 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,67 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "simple-syrup" +description = "Workflow-focused ComfyUI extensions for image generation." +version = "0.1.0" +license = "AGPL-3.0-or-later" +license-files = ["LICENSE"] +requires-python = ">=3.11" +dynamic = ["dependencies"] +classifiers = [ + "Operating System :: OS Independent", + "Environment :: GPU :: NVIDIA CUDA", +] + +[project.urls] +Repository = "https://github.com/Artificial-Sweetener/SimpleSyrup" +"Bug Tracker" = "https://github.com/Artificial-Sweetener/SimpleSyrup/issues" + +[tool.comfy] +PublisherId = "artificialsweetener" +DisplayName = "SimpleSyrup" +Icon = "" +includes = [] + +[tool.setuptools.dynamic] +dependencies = { file = ["requirements.txt"] } + +[tool.setuptools.packages.find] +include = ["simple_syrup*"] + +[tool.ruff] +line-length = 88 +target-version = "py311" +extend-exclude = [ + "simple_syrup/third_party/groundingdino_runtime", + "simple_syrup/third_party/sam_hq_runtime", +] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "C4", "ANN"] +ignore = ["ANN401"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +strict_equality = true +explicit_package_bases = true +exclude = [ + "simple_syrup/third_party/groundingdino_runtime", + "simple_syrup/third_party/sam_hq_runtime", +] + +[[tool.mypy.overrides]] +module = ["comfy.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +pythonpath = [".", "../.."] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d0d1464 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +torchlanc>=1.1.0 +ultralytics>=8.3.0 +onnxruntime>=1.22.0 +segment-anything>=1.0 +timm>=0.6.13 +addict>=2.4.0 +yapf>=0.43.0 +huggingface-hub>=0.34.0 diff --git a/scripts/update-release-versions.mjs b/scripts/update-release-versions.mjs new file mode 100644 index 0000000..52ec528 --- /dev/null +++ b/scripts/update-release-versions.mjs @@ -0,0 +1,48 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { readFileSync, writeFileSync } from "node:fs"; + +const nextVersion = process.argv[2]; + +if (!nextVersion) { + throw new Error("Expected the next release version as the first argument."); +} + +function writeJsonVersion(filePath) { + const metadata = JSON.parse(readFileSync(filePath, "utf8")); + metadata.version = nextVersion; + + if (metadata.packages?.[""]) { + metadata.packages[""].version = nextVersion; + } + + writeFileSync(filePath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); +} + +function replaceVersionField(filePath, pattern, replacement) { + const originalText = readFileSync(filePath, "utf8"); + + if (!pattern.test(originalText)) { + throw new Error(`Could not find a version field in ${filePath.pathname}.`); + } + + const updatedText = originalText.replace(pattern, replacement); + writeFileSync(filePath, updatedText, "utf8"); +} + +writeJsonVersion(new URL("../package.json", import.meta.url)); +writeJsonVersion(new URL("../package-lock.json", import.meta.url)); + +replaceVersionField( + new URL("../pyproject.toml", import.meta.url), + /^version = "[^"]+"\r?$/m, + `version = "${nextVersion}"`, +); + +replaceVersionField( + new URL("../simple_syrup/__init__.py", import.meta.url), + /^__version__ = "[^"]+"\r?$/m, + `__version__ = "${nextVersion}"`, +); diff --git a/simple_syrup/__init__.py b/simple_syrup/__init__.py new file mode 100644 index 0000000..d69e974 --- /dev/null +++ b/simple_syrup/__init__.py @@ -0,0 +1,11 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""SimpleSyrup package for organized ComfyUI node implementations.""" + +from __future__ import annotations + +__version__ = "0.1.0" + +__all__: list[str] = ["__version__"] diff --git a/simple_syrup/domain/__init__.py b/simple_syrup/domain/__init__.py new file mode 100644 index 0000000..fe43a63 --- /dev/null +++ b/simple_syrup/domain/__init__.py @@ -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 + +"""Domain behavior for SimpleSyrup nodes.""" diff --git a/simple_syrup/domain/conditioning_batch.py b/simple_syrup/domain/conditioning_batch.py new file mode 100644 index 0000000..ceb6be4 --- /dev/null +++ b/simple_syrup/domain/conditioning_batch.py @@ -0,0 +1,62 @@ +# 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 per-segment conditioning batches.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, TypeAlias + +Conditioning: TypeAlias = Any + + +@dataclass(frozen=True) +class ConditioningBatch: + """Store ordered conditioning entries for per-SEG selection.""" + + entries: tuple[Conditioning, ...] + + def __post_init__(self) -> None: + """Reject batches that cannot select a conditioning.""" + + if not self.entries: + raise ValueError( + "conditioning batch must contain at least one conditioning." + ) + + def select(self, index: int) -> Conditioning: + """Return the entry for an index, reusing the last entry as fallback.""" + + if index < 0: + raise ValueError("conditioning batch index must be non-negative.") + return self.entries[min(index, len(self.entries) - 1)] + + def append(self, conditioning: Conditioning) -> ConditioningBatch: + """Return a new batch with one conditioning appended.""" + + 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.""" + + if separator == "": + raise ValueError("separator must not be empty.") + pattern = rf"\s*{re.escape(separator)}\s*" + return tuple(re.split(pattern, text)) + + +def select_conditioning( + conditioning: Conditioning | ConditioningBatch, + index: int, +) -> Conditioning: + """Select per-index conditioning or broadcast normal conditioning unchanged.""" + + if isinstance(conditioning, ConditioningBatch): + return conditioning.select(index) + if index < 0: + raise ValueError("conditioning batch index must be non-negative.") + return conditioning diff --git a/simple_syrup/domain/detail_geometry.py b/simple_syrup/domain/detail_geometry.py new file mode 100644 index 0000000..a4d1f96 --- /dev/null +++ b/simple_syrup/domain/detail_geometry.py @@ -0,0 +1,64 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Pure geometry helpers for scale-factor detailing.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DetailScalePlan: + """Describe the scaled crop dimensions used for one detail pass.""" + + width: int + height: int + scale: float + unclamped_long_side: float + target_long_side: float + + +def build_detail_scale_plan( + detected_width: int, + detected_height: int, + crop_width: int, + crop_height: int, + scale_factor: float, + clamp_size: int, +) -> DetailScalePlan: + """Calculate a scaled crop size from crop-region geometry.""" + + _validate_positive_int("detected_width", detected_width) + _validate_positive_int("detected_height", detected_height) + _validate_positive_int("crop_width", crop_width) + _validate_positive_int("crop_height", crop_height) + if scale_factor <= 0.0: + raise ValueError("scale_factor must be greater than 0.") + if clamp_size < 0: + raise ValueError("clamp_size must be 0 or greater. Use 0 for no clamp.") + + crop_long_side = float(max(crop_width, crop_height)) + unclamped_long_side = crop_long_side * float(scale_factor) + target_long_side = unclamped_long_side + if clamp_size > 0: + target_long_side = min(unclamped_long_side, float(clamp_size)) + + scale = target_long_side / crop_long_side + width = max(1, int(round(crop_width * scale))) + height = max(1, int(round(crop_height * scale))) + return DetailScalePlan( + width=width, + height=height, + scale=scale, + unclamped_long_side=unclamped_long_side, + target_long_side=target_long_side, + ) + + +def _validate_positive_int(name: str, value: int) -> None: + """Reject non-positive integer dimensions.""" + + if int(value) <= 0: + raise ValueError(f"{name} must be greater than 0.") diff --git a/simple_syrup/domain/graph_provenance.py b/simple_syrup/domain/graph_provenance.py new file mode 100644 index 0000000..d3bfbba --- /dev/null +++ b/simple_syrup/domain/graph_provenance.py @@ -0,0 +1,38 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Domain models for graph-level image provenance tracing.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +GraphLink: TypeAlias = tuple[str, int] + + +@dataclass(frozen=True) +class VaeDecodeProvenance: + """Record the latent and VAE links that produced a decoded image.""" + + decode_node_id: str + image_output: GraphLink + samples_link: GraphLink + vae_link: GraphLink | None + + +@dataclass(frozen=True) +class BrokenProvenance: + """Describe why an image link cannot be treated as an unmodified decode.""" + + reason: str + node_id: str | None = None + class_type: str | None = None + + +@dataclass(frozen=True) +class PassthroughRule: + """Describe an exact graph pass-through from output slot to input name.""" + + input_name: str diff --git a/simple_syrup/domain/prompt_composition.py b/simple_syrup/domain/prompt_composition.py new file mode 100644 index 0000000..316b8a3 --- /dev/null +++ b/simple_syrup/domain/prompt_composition.py @@ -0,0 +1,19 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Prompt text composition helpers.""" + +from __future__ import annotations + + +def prefix_prompt(prefix: str, prompt: str) -> str: + """Return prompt text with a blank-safe comma-separated prefix.""" + + prefix_text = prefix.strip() + prompt_text = prompt.strip() + if not prefix_text: + return prompt_text + if not prompt_text: + return prefix_text + return f"{prefix_text}, {prompt_text}" diff --git a/simple_syrup/domain/prompt_style.py b/simple_syrup/domain/prompt_style.py new file mode 100644 index 0000000..ccbf2c5 --- /dev/null +++ b/simple_syrup/domain/prompt_style.py @@ -0,0 +1,40 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Format Prompt Control STYLE tags from validated option labels.""" + +from __future__ import annotations + +ENCODE_STYLE_VALUES: dict[str, str] = { + "A1111": "A1111", + "Comfy": "comfy", + "Comfy++": "comfy++", + "Compel": "compel", + "Down Weight": "down_weight", + "Perp": "perp", +} + +NORMALIZATION_VALUES: dict[str, str] = { + "none": "none", + "length": "length", + "mean": "mean", + "length+mean": "length+mean", +} + + +def format_style_tag(encode_style: str) -> str: + """Return a Prompt Control STYLE tag for an encode-style option.""" + + return f"STYLE({ENCODE_STYLE_VALUES[encode_style]}) " + + +def format_style_tag_with_normalization(encode_style: str, normalization: str) -> str: + """Return a Prompt Control STYLE tag with optional normalization.""" + + normalization_value = NORMALIZATION_VALUES[normalization] + + if normalization_value == "none": + return format_style_tag(encode_style) + + return f"STYLE({ENCODE_STYLE_VALUES[encode_style]}, {normalization_value}) " diff --git a/simple_syrup/domain/regional_detailing.py b/simple_syrup/domain/regional_detailing.py new file mode 100644 index 0000000..49cec28 --- /dev/null +++ b/simple_syrup/domain/regional_detailing.py @@ -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 +# +# Portions of this file incorporate behavior derived from +# multidiffusion-upscaler-for-automatic1111. See third_party/manifest.toml and +# third_party/NOTICE.md. + +"""Domain models for SEGS-driven regional diffusion detailing.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import torch + +from .conditioning_batch import Conditioning, ConditioningBatch +from .segs import CropRegion, NativeSegs, Segment + +OPERATION = "Detail SEGS as Regions" + + +@dataclass(frozen=True) +class SegmentConditioningPair: + """Bind one SEG to its exact per-region positive conditioning.""" + + index: int + segment: Segment + positive: Conditioning + + +@dataclass(frozen=True) +class ImageRegion: + """Represent one paired region in image-pixel space.""" + + index: int + label: str + crop_region: CropRegion + image_mask: torch.Tensor + positive: Conditioning + + +@dataclass(frozen=True) +class LatentBox: + """Represent one rectangular region in latent-space coordinates.""" + + x: int + y: int + width: int + height: int + + +@dataclass(frozen=True) +class LatentRegion: + """Represent one paired region in latent space.""" + + index: int + label: str + latent_box: LatentBox + latent_mask: torch.Tensor + positive: Conditioning + + +def pair_segments_with_conditioning( + segs: NativeSegs, + region_positive: object, + *, + image_height: int, + image_width: int, +) -> tuple[SegmentConditioningPair, ...]: + """Return SEGS paired exactly with regional positive conditioning.""" + + header, segments = segs + validate_segs_image_dimensions( + segs_height=header[0], + segs_width=header[1], + image_height=image_height, + image_width=image_width, + ) + if not segments: + return () + if not isinstance(region_positive, ConditioningBatch): + raise TypeError( + f"{OPERATION} requires region_positive to be CONDITIONING_BATCH." + ) + if len(region_positive.entries) != len(segments): + raise ValueError( + f"{OPERATION} requires region_positive to contain exactly one " + "conditioning entry per SEG: got " + f"{len(region_positive.entries)} conditioning entries for " + f"{len(segments)} SEGS." + ) + + pairs: list[SegmentConditioningPair] = [] + for index, (segment, positive) in enumerate( + zip(segments, region_positive.entries, strict=True) + ): + if isinstance(positive, ConditioningBatch): + raise TypeError( + f"{OPERATION} region_positive entry {index} must be a normal " + "CONDITIONING value, not CONDITIONING_BATCH." + ) + _validate_segment_within_image( + segment, + index=index, + image_height=image_height, + image_width=image_width, + ) + pairs.append( + SegmentConditioningPair( + index=index, + segment=segment, + positive=positive, + ) + ) + return tuple(pairs) + + +def validate_segs_image_dimensions( + *, + segs_height: int, + segs_width: int, + image_height: int, + image_width: int, +) -> None: + """Reject SEGS headers that do not describe the current image.""" + + if (segs_height, segs_width) == (image_height, image_width): + return + raise ValueError( + f"{OPERATION} requires SEGS header dimensions to match the image: " + f"SEGS is {segs_height}x{segs_width}, image is " + f"{image_height}x{image_width}." + ) + + +def _validate_segment_within_image( + segment: Segment, + *, + index: int, + image_height: int, + image_width: int, +) -> None: + """Reject a segment whose crop region falls outside the image.""" + + region = segment.crop_region + if region.right <= image_width and region.bottom <= image_height: + return + raise ValueError( + f"{OPERATION} SEG {index} ('{segment.label}') crop_region must fit " + f"inside the image; got ({region.left}, {region.top}, {region.right}, " + f"{region.bottom}) for image {image_height}x{image_width}." + ) diff --git a/simple_syrup/domain/segs.py b/simple_syrup/domain/segs.py new file mode 100644 index 0000000..66e943f --- /dev/null +++ b/simple_syrup/domain/segs.py @@ -0,0 +1,306 @@ +# 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 and adapters for Impact-compatible SEGS values.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from typing import NamedTuple, Protocol, TypeAlias, cast + + +class CropRegion(NamedTuple): + """Represent a crop region as left, top, right, bottom coordinates.""" + + left: int + top: int + right: int + bottom: int + + @property + def width(self) -> int: + """Return the region width.""" + + return self.right - self.left + + @property + def height(self) -> int: + """Return the region height.""" + + return self.bottom - self.top + + +class BoundingBox(NamedTuple): + """Represent a detection box as left, top, right, bottom coordinates.""" + + left: int + top: int + right: int + bottom: int + + @property + def width(self) -> int: + """Return the box width.""" + + return self.right - self.left + + @property + def height(self) -> int: + """Return the box height.""" + + return self.bottom - self.top + + +@dataclass(frozen=True) +class Segment: + """Represent one detected region in an Impact-compatible shape.""" + + cropped_image: object | None + cropped_mask: object + confidence: float + crop_region: CropRegion + bbox: BoundingBox + label: str + control_net_wrapper: object | None = None + + +class SegmentLike(Protocol): + """Describe the attribute surface required for SEGS compatibility.""" + + cropped_image: object | None + cropped_mask: object + confidence: float + crop_region: object + bbox: object + label: str + control_net_wrapper: object | None + + +SegsHeader: TypeAlias = tuple[int, int] +NativeSegs: TypeAlias = tuple[SegsHeader, tuple[Segment, ...]] +ImpactSegs: TypeAlias = tuple[SegsHeader, list[Segment]] +NativeSegsGroup: TypeAlias = tuple[NativeSegs, ...] +SortKey: TypeAlias = tuple[float, float, int, int, int] + +SORT_ORDER_OPTIONS: tuple[str, ...] = ( + "largest to smallest", + "smallest to largest", + "widest to thinnest", + "thinnest to widest", + "tallest to shortest", + "shortest to tallest", + "top to bottom", + "bottom to top", + "left to right", + "right to left", + "highest confidence first", + "lowest confidence first", +) + + +def coerce_segs(value: object) -> NativeSegs: + """Convert a native or Impact-style SEGS value into native immutable SEGS.""" + + if not isinstance(value, tuple) or len(value) != 2: + raise ValueError("SEGS must be a tuple of (header, segments).") + + header = _coerce_header(value[0]) + raw_segments = value[1] + if not isinstance(raw_segments, Iterable): + raise ValueError("SEGS segments must be iterable.") + + return header, tuple(coerce_segment(segment) for segment in raw_segments) + + +def coerce_segment(value: object) -> Segment: + """Convert a native or attribute-compatible segment into `Segment`.""" + + if isinstance(value, Segment): + _validate_region(value.crop_region, "crop_region") + _validate_box(value.bbox, "bbox") + return value + + required = ( + "cropped_image", + "cropped_mask", + "confidence", + "crop_region", + "bbox", + "label", + "control_net_wrapper", + ) + missing = [name for name in required if not hasattr(value, name)] + if missing: + names = ", ".join(missing) + raise ValueError(f"SEG item is missing required attribute(s): {names}.") + + segment_like = cast(SegmentLike, value) + return Segment( + cropped_image=segment_like.cropped_image, + cropped_mask=segment_like.cropped_mask, + confidence=float(segment_like.confidence), + crop_region=_coerce_region(segment_like.crop_region, "crop_region"), + bbox=_coerce_box(segment_like.bbox, "bbox"), + label=str(segment_like.label), + control_net_wrapper=segment_like.control_net_wrapper, + ) + + +def to_impact_compatible_segs(segs: NativeSegs) -> ImpactSegs: + """Return raw tuple/list SEGS that Impact-style consumers can read.""" + + header, segments = coerce_segs(segs) + return header, list(segments) + + +def coerce_segs_group(value: object) -> NativeSegsGroup: + """Convert a single SEGS or a Comfy list of SEGS into native SEGS.""" + + if _looks_like_segs(value): + return (coerce_segs(value),) + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + raise ValueError("SEGS group must be a SEGS payload or a sequence of SEGS.") + if len(value) == 0: + raise ValueError("SEGS group must contain one or more SEGS payloads.") + + group: list[NativeSegs] = [] + for index, item in enumerate(value, start=1): + try: + group.append(coerce_segs(item)) + except ValueError as exc: + raise ValueError(f"SEGS group item {index} is invalid: {exc}") from exc + return tuple(group) + + +def to_impact_compatible_segs_group(segs_group: NativeSegsGroup) -> list[ImpactSegs]: + """Return a list of Impact-compatible SEGS payloads.""" + + return [to_impact_compatible_segs(segs) for segs in segs_group] + + +def sort_segs(segs: NativeSegs, sort_order: str) -> NativeSegs: + """Return native SEGS sorted by a plain-English policy.""" + + if sort_order not in SORT_ORDER_OPTIONS: + raise ValueError(f"Unknown SEGS sort order: '{sort_order}'.") + header, segments = coerce_segs(segs) + indexed_segments = tuple(enumerate(segments)) + sorted_segments = sorted( + indexed_segments, + key=lambda item: _sort_key(item[1], item[0], sort_order), + ) + return header, tuple(segment for _index, segment in sorted_segments) + + +def _sort_key(segment: Segment, index: int, sort_order: str) -> SortKey: + """Build a deterministic sort key for one segment.""" + + region = segment.crop_region + confidence_desc = -float(segment.confidence) + if sort_order == "highest confidence first": + return confidence_desc, float(region.top), region.left, region.left, index + if sort_order == "lowest confidence first": + return ( + float(segment.confidence), + float(region.top), + region.left, + region.left, + index, + ) + + if sort_order == "largest to smallest": + primary = -float(region.width * region.height) + elif sort_order == "smallest to largest": + primary = float(region.width * region.height) + elif sort_order == "widest to thinnest": + primary = -float(region.width) + elif sort_order == "thinnest to widest": + primary = float(region.width) + elif sort_order == "tallest to shortest": + primary = -float(region.height) + elif sort_order == "shortest to tallest": + primary = float(region.height) + elif sort_order == "top to bottom": + primary = float(region.top) + elif sort_order == "bottom to top": + primary = -float(region.top) + elif sort_order == "left to right": + primary = float(region.left) + elif sort_order == "right to left": + primary = -float(region.left) + else: + raise ValueError(f"Unknown SEGS sort order: '{sort_order}'.") + return primary, confidence_desc, region.top, region.left, index + + +def _coerce_header(value: object) -> SegsHeader: + """Convert a SEGS header to an image shape tuple.""" + + if not isinstance(value, Sequence) or len(value) != 2: + raise ValueError("SEGS header must contain exactly height and width.") + height = int(value[0]) + width = int(value[1]) + if height <= 0 or width <= 0: + raise ValueError("SEGS header height and width must be positive.") + return height, width + + +def _looks_like_segs(value: object) -> bool: + """Return whether a value has the outer shape of one SEGS payload.""" + + if not isinstance(value, tuple) or len(value) != 2: + return False + return _looks_like_header(value[0]) and isinstance(value[1], Iterable) + + +def _looks_like_header(value: object) -> bool: + """Return whether a value can represent a SEGS header.""" + + if not isinstance(value, Sequence) or len(value) != 2: + return False + try: + int(value[0]) + int(value[1]) + except (TypeError, ValueError): + return False + return True + + +def _coerce_region(value: object, name: str) -> CropRegion: + """Convert a four-value coordinate sequence into a crop region.""" + + if not isinstance(value, Sequence) or len(value) != 4: + raise ValueError(f"{name} must contain left, top, right, bottom.") + region = CropRegion(int(value[0]), int(value[1]), int(value[2]), int(value[3])) + _validate_region(region, name) + return region + + +def _coerce_box(value: object, name: str) -> BoundingBox: + """Convert a four-value coordinate sequence into a bounding box.""" + + if not isinstance(value, Sequence) or len(value) != 4: + raise ValueError(f"{name} must contain left, top, right, bottom.") + box = BoundingBox(int(value[0]), int(value[1]), int(value[2]), int(value[3])) + _validate_box(box, name) + return box + + +def _validate_region(region: CropRegion, name: str) -> None: + """Validate crop-region coordinate ordering.""" + + if region.left < 0 or region.top < 0: + raise ValueError(f"{name} left and top must be non-negative.") + if region.right <= region.left or region.bottom <= region.top: + raise ValueError(f"{name} right/bottom must be greater than left/top.") + + +def _validate_box(box: BoundingBox, name: str) -> None: + """Validate bounding-box coordinate ordering.""" + + if box.left < 0 or box.top < 0: + raise ValueError(f"{name} left and top must be non-negative.") + if box.right <= box.left or box.bottom <= box.top: + raise ValueError(f"{name} right/bottom must be greater than left/top.") diff --git a/simple_syrup/domain/tile_segs.py b/simple_syrup/domain/tile_segs.py new file mode 100644 index 0000000..6b340f6 --- /dev/null +++ b/simple_syrup/domain/tile_segs.py @@ -0,0 +1,247 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Domain policy for deterministic tile SEGS construction.""" + +from __future__ import annotations + +import math +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, + CropRegion, + NativeSegs, + Segment, +) + +LOGGER = get_logger(__name__) + +IRREGULAR_MASK_MODES: tuple[str, ...] = ( + "Reuse fast", + "Reuse quality", + "All random fast", + "All random quality", +) + + +@dataclass(frozen=True) +class TileSEGSControls: + """Validate controls for tile SEGS construction.""" + + bbox_size: int + crop_factor: float + min_overlap: int + filter_segs_dilation: int + mask_irregularity: float + irregular_mask_mode: str + + def __post_init__(self) -> None: + """Reject controls that would make tile layout ambiguous.""" + + if self.bbox_size < 1: + raise ValueError("bbox_size must be greater than 0.") + if self.crop_factor < 1.0: + raise ValueError("crop_factor must be greater than or equal to 1.0.") + if self.min_overlap < 0: + raise ValueError("min_overlap must be greater than or equal to 0.") + if not 0.0 <= self.mask_irregularity <= 1.0: + raise ValueError("mask_irregularity must be between 0.0 and 1.0.") + if self.irregular_mask_mode not in IRREGULAR_MASK_MODES: + raise ValueError( + f"irregular_mask_mode must be one of {IRREGULAR_MASK_MODES}." + ) + + +@dataclass(frozen=True) +class TileRegion: + """Describe one tile bbox and expanded crop region.""" + + bbox: BoundingBox + crop_region: CropRegion + + +class TileSEGSBuilder: + """Build Impact-compatible tile SEGS from an image and optional filters.""" + + def build( + self, + image: object, + controls: TileSEGSControls, + ) -> NativeSegs: + """Return native tile SEGS in deterministic scan order.""" + + image_tensor = validate_single_image(image, "Tile & Tag SEGS") + height = int(image_tensor.shape[1]) + width = int(image_tensor.shape[2]) + bbox_size, min_overlap = _adjust_tile_controls(width, height, controls) + tile_regions = _tile_regions( + width=width, + height=height, + bbox_size=bbox_size, + min_overlap=min_overlap, + crop_factor=controls.crop_factor, + ) + mask_factory = _IrregularMaskFactory(controls) + segments: list[Segment] = [] + for tile_index, tile_region in enumerate(tile_regions): + mask = _tile_crop_mask(tile_region) + if controls.mask_irregularity > 0: + irregular = mask_factory.mask_like(mask, tile_index) + mask = (mask * irregular).clamp(0.0, 1.0) + if bool(torch.all(mask == 0.0).item()): + continue + label = f"tile_{len(segments) + 1:03}" + segments.append( + Segment( + cropped_image=None, + cropped_mask=mask, + confidence=1.0, + crop_region=tile_region.crop_region, + bbox=tile_region.bbox, + label=label, + ) + ) + return (height, width), tuple(segments) + + +class _IrregularMaskFactory: + """Generate deterministic irregular masks for tile blending.""" + + def __init__(self, controls: TileSEGSControls) -> None: + """Initialize the mask generator from node controls.""" + + self._controls = controls + self._reused_noise: torch.Tensor | None = None + + def mask_like(self, reference: torch.Tensor, tile_index: int) -> torch.Tensor: + """Return a soft irregular mask with the same shape as reference.""" + + height = int(reference.shape[0]) + width = int(reference.shape[1]) + if self._controls.irregular_mask_mode.startswith("Reuse"): + if self._reused_noise is None: + self._reused_noise = self._noise(height, width, seed=17) + noise = self._reused_noise + else: + noise = self._noise(height, width, seed=101 + tile_index) + threshold = 1.0 - (self._controls.mask_irregularity * 0.5) + return torch.where( + noise > threshold, torch.zeros_like(noise), torch.ones_like(noise) + ) + + def _noise(self, height: int, width: int, seed: int) -> torch.Tensor: + """Return deterministic low-frequency noise resized to tile shape.""" + + generator = torch.Generator(device="cpu").manual_seed(seed) + quality = 16 if self._controls.irregular_mask_mode.endswith("fast") else 64 + base = torch.rand((quality, quality), generator=generator, dtype=torch.float32) + resized = resize_mask(base, height, width) + return resized.clamp(0.0, 1.0) + + +def _adjust_tile_controls( + width: int, + height: int, + controls: TileSEGSControls, +) -> tuple[int, int]: + """Clamp tile controls to valid values for the image dimensions.""" + + bbox_size = min(controls.bbox_size, width, height) + if bbox_size != controls.bbox_size: + LOGGER.warning( + "Tile & Tag SEGS clamped bbox_size to image dimensions", + extra={ + "operation": "tile_and_tag_segs", + "requested_bbox_size": controls.bbox_size, + "bbox_size": bbox_size, + }, + ) + min_overlap = controls.min_overlap + if bbox_size <= 2 * min_overlap: + adjusted = max(0, (bbox_size // 2) - 1) + LOGGER.warning( + "Tile & Tag SEGS adjusted min_overlap for valid tile stepping", + extra={ + "operation": "tile_and_tag_segs", + "requested_min_overlap": min_overlap, + "min_overlap": adjusted, + "bbox_size": bbox_size, + }, + ) + min_overlap = adjusted + return bbox_size, min_overlap + + +def _tile_regions( + *, + width: int, + height: int, + bbox_size: int, + min_overlap: int, + crop_factor: float, +) -> tuple[TileRegion, ...]: + """Return row-major tile regions covering the image.""" + + x_positions = _axis_positions(width, bbox_size, min_overlap) + y_positions = _axis_positions(height, bbox_size, min_overlap) + regions: list[TileRegion] = [] + for top in y_positions: + for left in x_positions: + bbox = BoundingBox(left, top, left + bbox_size, top + bbox_size) + regions.append( + TileRegion( + bbox=bbox, + crop_region=crop_region_for_bbox( + bbox, + image_height=height, + image_width=width, + crop_factor=crop_factor, + ), + ) + ) + return tuple(regions) + + +def _axis_positions(length: int, bbox_size: int, min_overlap: int) -> tuple[int, ...]: + """Return tile starts for one axis with even overlap distribution.""" + + if bbox_size >= length: + return (0,) + step = bbox_size - min_overlap + tile_count = max(1, math.ceil(length / step)) + overlap_sum = bbox_size * tile_count - length + if overlap_sum < 0: + tile_count += 1 + overlap_sum = bbox_size * tile_count - length + overlap = 0 if tile_count == 1 else int(overlap_sum / (tile_count - 1)) + if overlap == bbox_size: + return (0,) + starts = [] + position = 0 + for _index in range(tile_count): + starts.append(min(position, length - bbox_size)) + position += bbox_size - overlap + return tuple(dict.fromkeys(starts)) + + +def _tile_crop_mask(tile_region: TileRegion) -> torch.Tensor: + """Return a rectangular tile mask in crop-region coordinates.""" + + crop = tile_region.crop_region + bbox = tile_region.bbox + mask = torch.zeros((crop.height, crop.width), dtype=torch.float32) + mask[ + bbox.top - crop.top : bbox.bottom - crop.top, + bbox.left - crop.left : bbox.right - crop.left, + ] = 1.0 + return mask diff --git a/simple_syrup/domain/tiled_diffusion.py b/simple_syrup/domain/tiled_diffusion.py new file mode 100644 index 0000000..af3c894 --- /dev/null +++ b/simple_syrup/domain/tiled_diffusion.py @@ -0,0 +1,230 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Portions of this file incorporate behavior derived from +# multidiffusion-upscaler-for-automatic1111. See third_party/manifest.toml and +# third_party/NOTICE.md. + +"""Pure tiled diffusion planning and mode validation.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import torch + +TILED_DIFFUSION_MODES = ("multidiffusion", "mixture_of_diffusers") + + +@dataclass(frozen=True) +class LatentTile: + """Describe one rectangular latent-space tile.""" + + x: int + y: int + width: int + height: int + + @property + def slicer(self) -> tuple[slice, slice, slice, slice]: + """Return a tensor slicer for this tile on BCHW latents.""" + + return ( + slice(None), + slice(None), + slice(self.y, self.y + self.height), + slice(self.x, self.x + self.width), + ) + + +@dataclass(frozen=True) +class TiledDiffusionPlan: + """Describe deterministic latent tiles and balanced tile batches.""" + + latent_width: int + latent_height: int + tile_width: int + tile_height: int + overlap: int + requested_tile_batch_size: int + tile_batch_size: int + tiles: tuple[LatentTile, ...] + batches: tuple[tuple[LatentTile, ...], ...] + + +def build_tiled_diffusion_plan( + latent_width: int, + latent_height: int, + tile_width: int, + tile_height: int, + overlap: int, + tile_batch_size: int, +) -> TiledDiffusionPlan: + """Build a deterministic latent tile plan for tiled denoising.""" + + _validate_plan_inputs( + latent_width=latent_width, + latent_height=latent_height, + tile_width=tile_width, + tile_height=tile_height, + tile_batch_size=tile_batch_size, + ) + 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)) + + tiles = _split_tiles( + latent_width=latent_width, + latent_height=latent_height, + tile_width=effective_tile_width, + tile_height=effective_tile_height, + overlap=effective_overlap, + ) + batches, effective_tile_batch_size = _batch_tiles(tiles, tile_batch_size) + return TiledDiffusionPlan( + latent_width=latent_width, + latent_height=latent_height, + tile_width=effective_tile_width, + tile_height=effective_tile_height, + overlap=effective_overlap, + requested_tile_batch_size=tile_batch_size, + tile_batch_size=effective_tile_batch_size, + tiles=tiles, + batches=batches, + ) + + +def tile_is_splittable( + latent_width: int, + latent_height: int, + tile_width: int, + tile_height: int, + overlap: int, +) -> bool: + """Return whether the tile grid produces more than one tile.""" + + 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=1, + ) + return len(plan.tiles) > 1 + + +def validate_tiled_diffusion_mode(diffusion_mode: str) -> None: + """Reject unsupported tiled diffusion modes.""" + + if diffusion_mode in TILED_DIFFUSION_MODES: + return + supported = ", ".join(TILED_DIFFUSION_MODES) + raise ValueError( + f"diffusion_mode must be one of: {supported}; got {diffusion_mode!r}." + ) + + +def gaussian_tile_weights( + tile_width: int, + tile_height: int, + *, + device: torch.device, + dtype: torch.dtype, +) -> torch.Tensor: + """Return Mixture of Diffusers Gaussian tile weights.""" + + if tile_width < 4: + raise ValueError("tile_width must be at least 4.") + if tile_height < 4: + raise ValueError("tile_height must be at least 4.") + + x_values = torch.arange(tile_width, device=device, dtype=torch.float64) + y_values = torch.arange(tile_height, device=device, dtype=torch.float64) + variance = 0.01 + + x_midpoint = (tile_width - 1) / 2 + y_midpoint = tile_height / 2 + denominator = math.sqrt(2 * math.pi * variance) + x_probs = ( + torch.exp( + -((x_values - x_midpoint) * (x_values - x_midpoint)) + / (tile_width * tile_width) + / (2 * variance) + ) + / denominator + ) + y_probs = ( + torch.exp( + -((y_values - y_midpoint) * (y_values - y_midpoint)) + / (tile_width * tile_width) + / (2 * variance) + ) + / denominator + ) + return torch.outer(y_probs, x_probs).to(dtype=dtype) + + +def _validate_plan_inputs( + *, + latent_width: int, + latent_height: int, + tile_width: int, + tile_height: int, + tile_batch_size: int, +) -> None: + """Reject invalid tile planning values before sampling.""" + + if latent_width < 1: + raise ValueError("latent_width must be at least 1.") + if latent_height < 1: + raise ValueError("latent_height must be at least 1.") + if tile_width < 4: + raise ValueError("tile_width must be at least 4.") + if tile_height < 4: + raise ValueError("tile_height must be at least 4.") + if tile_batch_size < 1: + raise ValueError("tile_batch_size must be at least 1.") + + +def _split_tiles( + *, + latent_width: int, + latent_height: int, + tile_width: int, + tile_height: int, + overlap: int, +) -> tuple[LatentTile, ...]: + """Split a latent with a symmetric row-major grid formula.""" + + cols = math.ceil((latent_width - overlap) / (tile_width - overlap)) + rows = math.ceil((latent_height - overlap) / (tile_height - overlap)) + dx = (latent_width - tile_width) / (cols - 1) if cols > 1 else 0 + dy = (latent_height - tile_height) / (rows - 1) if rows > 1 else 0 + + tiles: list[LatentTile] = [] + for row in range(rows): + y = min(int(row * dy), latent_height - tile_height) + for col in range(cols): + x = min(int(col * dx), latent_width - tile_width) + tiles.append(LatentTile(x, y, tile_width, tile_height)) + return tuple(tiles) + + +def _batch_tiles( + tiles: tuple[LatentTile, ...], + requested_tile_batch_size: int, +) -> tuple[tuple[tuple[LatentTile, ...], ...], int]: + """Group tiles using balanced effective tile batch sizing.""" + + num_batches = math.ceil(len(tiles) / requested_tile_batch_size) + effective_tile_batch_size = math.ceil(len(tiles) / num_batches) + batches = tuple( + tiles[ + index * effective_tile_batch_size : (index + 1) * effective_tile_batch_size + ] + for index in range(num_batches) + ) + return batches, effective_tile_batch_size diff --git a/simple_syrup/image/__init__.py b/simple_syrup/image/__init__.py new file mode 100644 index 0000000..67926db --- /dev/null +++ b/simple_syrup/image/__init__.py @@ -0,0 +1,9 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Image-domain services and policies for SimpleSyrup.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/simple_syrup/image/crop_composite.py b/simple_syrup/image/crop_composite.py new file mode 100644 index 0000000..ca9a4d2 --- /dev/null +++ b/simple_syrup/image/crop_composite.py @@ -0,0 +1,40 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Image crop compositing helpers for detailer services.""" + +from __future__ import annotations + +import torch + +from ..domain.segs import CropRegion + + +def composite_crop( + image: torch.Tensor, + crop: torch.Tensor, + mask: torch.Tensor, + region: CropRegion, +) -> torch.Tensor: + """Alpha-composite a detailed crop back into a BHWC image tensor.""" + + if image.ndim != 4 or crop.ndim != 4: + raise ValueError("image and crop must be BHWC tensors.") + if mask.ndim != 2: + raise ValueError("composite mask must be an HW tensor.") + expected_height = region.height + expected_width = region.width + if int(crop.shape[1]) != expected_height or int(crop.shape[2]) != expected_width: + raise ValueError("detailed crop dimensions must match crop region.") + if int(mask.shape[0]) != expected_height or int(mask.shape[1]) != expected_width: + raise ValueError("composite mask dimensions must match crop region.") + + output = image.clone() + alpha = mask.to(device=image.device, dtype=image.dtype).clamp(0.0, 1.0) + alpha_bhwc = alpha.unsqueeze(0).unsqueeze(-1) + original = output[:, region.top : region.bottom, region.left : region.right, :] + blended = crop.to(device=image.device, dtype=image.dtype) * alpha_bhwc + blended = blended + original * (1.0 - alpha_bhwc) + output[:, region.top : region.bottom, region.left : region.right, :] = blended + return output.clamp(0.0, 1.0) diff --git a/simple_syrup/image/resize_geometry.py b/simple_syrup/image/resize_geometry.py new file mode 100644 index 0000000..6600c28 --- /dev/null +++ b/simple_syrup/image/resize_geometry.py @@ -0,0 +1,375 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Pure geometry planning for target image resizing.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import StrEnum + + +class ResizeMode(StrEnum): + """Supported target resize modes.""" + + STRETCH = "Stretch" + KEEP_AR = "Keep AR" + CROP = "Crop (Cover + Crop)" + PAD = "Pad (Fit + Pad)" + + +class CropPosition(StrEnum): + """Supported crop and pad anchor positions.""" + + CENTER = "center" + TOP_LEFT = "top-left" + TOP = "top" + TOP_RIGHT = "top-right" + LEFT = "left" + RIGHT = "right" + BOTTOM_LEFT = "bottom-left" + BOTTOM = "bottom" + BOTTOM_RIGHT = "bottom-right" + + +@dataclass(frozen=True) +class ResizeTarget: + """Requested output bounds and divisibility constraint.""" + + width: int + height: int + divisible_by: int = 1 + + +@dataclass(frozen=True) +class ResizePlan: + """Concrete resize, crop, and pad geometry for an image batch.""" + + resize_width: int + resize_height: int + output_width: int + output_height: int + crop_x: int = 0 + crop_y: int = 0 + pad_left: int = 0 + pad_right: int = 0 + pad_top: int = 0 + pad_bottom: int = 0 + + @property + def has_crop(self) -> bool: + """Return whether the plan crops after resizing.""" + + return self.crop_x > 0 or self.crop_y > 0 + + @property + def has_pad(self) -> bool: + """Return whether the plan pads after resizing.""" + + return any( + side > 0 + for side in (self.pad_left, self.pad_right, self.pad_top, self.pad_bottom) + ) + + +def build_resize_plan( + source_width: int, + source_height: int, + target: ResizeTarget, + mode: ResizeMode | str, + position: CropPosition | str, +) -> ResizePlan: + """Build resize geometry for the requested mode and source dimensions.""" + + _validate_positive_dimension(source_width, "source_width") + _validate_positive_dimension(source_height, "source_height") + _validate_positive_dimension(target.width, "target.width") + _validate_positive_dimension(target.height, "target.height") + + normalized_mode = _coerce_resize_mode(mode) + normalized_position = _coerce_crop_position(position) + divisible_by = _normalize_divisible_by(target.divisible_by) + + if normalized_mode is ResizeMode.STRETCH: + output_width, output_height = apply_divisibility( + target.width, + target.height, + divisible_by, + ) + return ResizePlan( + resize_width=output_width, + resize_height=output_height, + output_width=output_width, + output_height=output_height, + ) + + if normalized_mode is ResizeMode.KEEP_AR: + resize_width, resize_height = fit_inside( + source_width, + source_height, + target.width, + target.height, + ) + output_width, output_height = apply_divisibility( + resize_width, + resize_height, + divisible_by, + ) + return ResizePlan( + resize_width=output_width, + resize_height=output_height, + output_width=output_width, + output_height=output_height, + ) + + output_width, output_height = apply_divisibility( + target.width, + target.height, + divisible_by, + ) + + if normalized_mode is ResizeMode.CROP: + resize_width, resize_height = cover_bounds( + source_width, + source_height, + output_width, + output_height, + ) + crop_x, crop_y = calculate_crop_offsets( + normalized_position, + resize_width, + resize_height, + output_width, + output_height, + ) + return ResizePlan( + resize_width=resize_width, + resize_height=resize_height, + output_width=output_width, + output_height=output_height, + crop_x=crop_x, + crop_y=crop_y, + ) + + if normalized_mode is ResizeMode.PAD: + resize_width, resize_height = fit_inside( + source_width, + source_height, + output_width, + output_height, + ) + pad_width = output_width - resize_width + pad_height = output_height - resize_height + pad_left, pad_right, pad_top, pad_bottom = calculate_pad_sides( + normalized_position, + pad_width, + pad_height, + ) + return ResizePlan( + resize_width=resize_width, + resize_height=resize_height, + output_width=output_width, + output_height=output_height, + pad_left=pad_left, + pad_right=pad_right, + pad_top=pad_top, + pad_bottom=pad_bottom, + ) + + raise ValueError(f"Unsupported resize mode: {mode!r}") + + +def fit_inside( + source_width: int, + source_height: int, + target_width: int, + target_height: int, +) -> tuple[int, int]: + """Return dimensions that fit inside target bounds while preserving aspect.""" + + _validate_positive_dimension(source_width, "source_width") + _validate_positive_dimension(source_height, "source_height") + _validate_positive_dimension(target_width, "target_width") + _validate_positive_dimension(target_height, "target_height") + + scale = min(target_width / source_width, target_height / source_height) + return ( + max(1, int(round(source_width * scale))), + max(1, int(round(source_height * scale))), + ) + + +def cover_bounds( + source_width: int, + source_height: int, + target_width: int, + target_height: int, +) -> tuple[int, int]: + """Return dimensions that cover target bounds while preserving aspect.""" + + _validate_positive_dimension(source_width, "source_width") + _validate_positive_dimension(source_height, "source_height") + _validate_positive_dimension(target_width, "target_width") + _validate_positive_dimension(target_height, "target_height") + + scale = max(target_width / source_width, target_height / source_height) + return ( + max(1, int(math.ceil(source_width * scale))), + max(1, int(math.ceil(source_height * scale))), + ) + + +def apply_divisibility( + width: int, + height: int, + divisible_by: int, +) -> tuple[int, int]: + """Step dimensions down to positive multiples of the divisibility value.""" + + _validate_positive_dimension(width, "width") + _validate_positive_dimension(height, "height") + normalized = _normalize_divisible_by(divisible_by) + + if normalized <= 1: + return width, height + + output_width = width - (width % normalized) + output_height = height - (height % normalized) + if output_width <= 0 or output_height <= 0: + raise ValueError( + "Requested dimensions cannot satisfy divisible_by=" + f"{normalized}: width={width}, height={height}." + ) + return output_width, output_height + + +def calculate_crop_offsets( + position: CropPosition | str, + resized_width: int, + resized_height: int, + output_width: int, + output_height: int, +) -> tuple[int, int]: + """Return the x and y offsets for cropping resized content.""" + + normalized_position = _coerce_crop_position(position) + _validate_positive_dimension(resized_width, "resized_width") + _validate_positive_dimension(resized_height, "resized_height") + _validate_positive_dimension(output_width, "output_width") + _validate_positive_dimension(output_height, "output_height") + + if resized_width < output_width or resized_height < output_height: + raise ValueError( + "Crop dimensions must be at least as large as output dimensions: " + f"resized={resized_width}x{resized_height}, " + f"output={output_width}x{output_height}." + ) + + extra_width = resized_width - output_width + extra_height = resized_height - output_height + return ( + _offset_for_axis(normalized_position, extra_width, horizontal=True), + _offset_for_axis(normalized_position, extra_height, horizontal=False), + ) + + +def calculate_pad_sides( + position: CropPosition | str, + pad_width: int, + pad_height: int, +) -> tuple[int, int, int, int]: + """Return left, right, top, and bottom padding for an anchor position.""" + + normalized_position = _coerce_crop_position(position) + if pad_width < 0 or pad_height < 0: + raise ValueError( + f"Padding cannot be negative: pad_width={pad_width}, " + f"pad_height={pad_height}." + ) + + left = _offset_for_axis(normalized_position, pad_width, horizontal=True) + top = _offset_for_axis(normalized_position, pad_height, horizontal=False) + right = pad_width - left + bottom = pad_height - top + return left, right, top, bottom + + +def _offset_for_axis( + position: CropPosition, + extra: int, + *, + horizontal: bool, +) -> int: + """Resolve a crop or pad offset along one axis.""" + + if extra <= 0: + return 0 + + if horizontal: + if position in { + CropPosition.TOP_LEFT, + CropPosition.LEFT, + CropPosition.BOTTOM_LEFT, + }: + return 0 + if position in { + CropPosition.TOP_RIGHT, + CropPosition.RIGHT, + CropPosition.BOTTOM_RIGHT, + }: + return extra + return extra // 2 + + if position in { + CropPosition.TOP_LEFT, + CropPosition.TOP, + CropPosition.TOP_RIGHT, + }: + return 0 + if position in { + CropPosition.BOTTOM_LEFT, + CropPosition.BOTTOM, + CropPosition.BOTTOM_RIGHT, + }: + return extra + return extra // 2 + + +def _coerce_resize_mode(mode: ResizeMode | str) -> ResizeMode: + """Convert a raw resize mode value into a supported enum.""" + + try: + return mode if isinstance(mode, ResizeMode) else ResizeMode(str(mode)) + except ValueError as exc: + raise ValueError(f"Unsupported resize mode: {mode!r}") from exc + + +def _coerce_crop_position(position: CropPosition | str) -> CropPosition: + """Convert a raw crop position value into a supported enum.""" + + try: + return ( + position + if isinstance(position, CropPosition) + else CropPosition(str(position)) + ) + except ValueError as exc: + raise ValueError(f"Unsupported crop_position: {position!r}") from exc + + +def _normalize_divisible_by(divisible_by: int) -> int: + """Validate and normalize the divisibility constraint.""" + + normalized = int(divisible_by) + if normalized < 1: + raise ValueError(f"divisible_by must be at least 1, got {divisible_by}.") + return normalized + + +def _validate_positive_dimension(value: int, name: str) -> None: + """Validate a positive integer dimension.""" + + if int(value) <= 0: + raise ValueError(f"{name} must be greater than 0, got {value}.") diff --git a/simple_syrup/image/resize_service.py b/simple_syrup/image/resize_service.py new file mode 100644 index 0000000..0c9fc44 --- /dev/null +++ b/simple_syrup/image/resize_service.py @@ -0,0 +1,369 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Application service for resizing ComfyUI image batches.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Protocol, cast + +import torch + +from ..runtime.image_resamplers import ( + NativeComfyResampler, + Processor, + validate_sampling, +) +from ..runtime.torchlanc_resampler import TorchLanczosResampler +from ..shared.logging import get_logger +from ..shared.tensor_validation import ( + ImageTensorShape, + validate_image_tensor, + validate_mask_tensor, +) +from .resize_geometry import ( + CropPosition, + ResizeMode, + ResizePlan, + ResizeTarget, + build_resize_plan, +) + + +class ProgressReporter(Protocol): + """Progress reporting interface used by the resize service.""" + + def update(self, value: int) -> None: + """Advance progress by the given number of items.""" + + +NativeResamplerFactory = Callable[[Processor], NativeComfyResampler] +TorchLancFactory = Callable[[], TorchLanczosResampler] +ProgressFactory = Callable[[int], ProgressReporter] + +_LOGGER = get_logger(__name__) + + +class ResizeImageToTargetService: + """Resize ComfyUI image batches to a target geometry.""" + + def __init__( + self, + native_resampler_factory: NativeResamplerFactory | None = None, + torchlanc_factory: TorchLancFactory | None = None, + progress_factory: ProgressFactory | None = None, + ) -> None: + """Create the service with injectable runtime boundaries.""" + + self._native_resampler_factory = native_resampler_factory + self._torchlanc_factory = torchlanc_factory + self._progress_factory = progress_factory + + def resize( + self, + image: torch.Tensor, + width: int, + height: int, + resize_mode: str, + sampling: str, + processor: str, + divisible_by: int, + crop_position: str, + pad_color: str, + max_batch_size: int, + sinc_window: int, + precision: str, + mask: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, int, int, torch.Tensor]: + """Return resized image, output width, output height, and resized mask.""" + + try: + image_shape = validate_image_tensor(image) + if mask is not None: + validate_mask_tensor(mask, image_shape.batch_size) + validate_sampling(sampling) + normalized_processor = _coerce_processor(processor) + normalized_mode = ResizeMode(resize_mode) + normalized_position = CropPosition(crop_position) + plan = build_resize_plan( + image_shape.width, + image_shape.height, + ResizeTarget( + width=int(width), height=int(height), divisible_by=int(divisible_by) + ), + normalized_mode, + normalized_position, + ) + _LOGGER.debug( + "resize_start", + extra={ + "source_width": image_shape.width, + "source_height": image_shape.height, + "output_width": plan.output_width, + "output_height": plan.output_height, + "mode": normalized_mode.value, + "sampling": sampling, + "processor": normalized_processor, + "batch_size": image_shape.batch_size, + }, + ) + output = self._resize_validated( + image=image, + image_shape=image_shape, + plan=plan, + sampling=sampling, + processor=normalized_processor, + pad_color=pad_color, + max_batch_size=int(max_batch_size), + sinc_window=int(sinc_window), + precision=precision, + mask=mask, + ) + _LOGGER.debug( + "resize_complete", + extra={ + "output_width": plan.output_width, + "output_height": plan.output_height, + "batch_size": image_shape.batch_size, + }, + ) + return output + except Exception: + _LOGGER.exception( + "resize_failed", + extra={ + "width": width, + "height": height, + "resize_mode": resize_mode, + "sampling": sampling, + "processor": processor, + }, + ) + raise + + def _resize_validated( + self, + image: torch.Tensor, + image_shape: ImageTensorShape, + plan: ResizePlan, + sampling: str, + processor: Processor, + pad_color: str, + max_batch_size: int, + sinc_window: int, + precision: str, + mask: torch.Tensor | None, + ) -> tuple[torch.Tensor, int, int, torch.Tensor]: + """Execute resizing after public inputs have been validated.""" + + image_bchw = image.float().clamp(0.0, 1.0).movedim(-1, 1) + mask_bchw = ( + mask.float().clamp(0.0, 1.0).unsqueeze(1) if mask is not None else None + ) + pad_values = parse_pad_color(pad_color, image_shape.channels) + output_images: list[torch.Tensor] = [] + output_masks: list[torch.Tensor] = [] + progress = self._create_progress(image_shape.batch_size) + + for start, end in _chunk_spans(image_shape.batch_size, max_batch_size): + image_chunk = image_bchw[start:end] + resized = self._resize_image_chunk( + image_chunk, + plan, + sampling, + processor, + sinc_window, + precision, + ) + finalized = _apply_crop_and_pad_image(resized, plan, pad_values) + output_images.append(finalized.to("cpu").movedim(1, -1)) + + if mask_bchw is not None: + mask_chunk = mask_bchw[start:end] + resized_mask = self._native_resampler(processor).resize( + mask_chunk, + plan.resize_width, + plan.resize_height, + "nearest-exact", + ) + finalized_mask = _apply_crop_and_pad_mask(resized_mask, plan) + output_masks.append(finalized_mask.to("cpu").squeeze(1)) + + progress.update(end - start) + + image_out = torch.cat(output_images, dim=0) + if output_masks: + mask_out = torch.cat(output_masks, dim=0) + else: + mask_out = torch.zeros( + (image_shape.batch_size, plan.output_height, plan.output_width), + dtype=torch.float32, + ) + + return image_out, plan.output_width, plan.output_height, mask_out + + def _resize_image_chunk( + self, + samples: torch.Tensor, + plan: ResizePlan, + sampling: str, + processor: Processor, + sinc_window: int, + precision: str, + ) -> torch.Tensor: + """Route one image chunk to the selected runtime backend.""" + + with torch.inference_mode(): + if processor == "gpu" and sampling == "lanczos": + return self._torchlanc_resampler().resize( + samples, + plan.resize_width, + plan.resize_height, + sinc_window, + precision, + ) + + return self._native_resampler(processor).resize( + samples, + plan.resize_width, + plan.resize_height, + sampling, + ) + + def _native_resampler(self, processor: Processor) -> NativeComfyResampler: + """Create the native resampler for the current processor.""" + + if self._native_resampler_factory is not None: + return self._native_resampler_factory(processor) + return NativeComfyResampler(processor) + + def _torchlanc_resampler(self) -> TorchLanczosResampler: + """Create the TorchLanc resampler.""" + + if self._torchlanc_factory is not None: + return self._torchlanc_factory() + return TorchLanczosResampler() + + def _create_progress(self, total: int) -> ProgressReporter: + """Create a progress reporter for the batch.""" + + if self._progress_factory is not None: + return self._progress_factory(total) + + import comfy.utils + + return cast(ProgressReporter, comfy.utils.ProgressBar(total)) + + +def parse_pad_color(color: str, channels: int) -> torch.Tensor: + """Parse an RGB padding color into a normalized channel tensor.""" + + if channels not in (1, 3, 4): + raise ValueError(f"Unsupported channel count {channels}. Expected 1, 3, or 4.") + + parts = [part.strip() for part in color.split(",")] + if len(parts) != 3: + raise ValueError( + "pad_color must contain exactly three comma-separated RGB values." + ) + + values: list[float] = [] + for part in parts: + try: + value = int(part) + except ValueError as exc: + raise ValueError( + f"pad_color value {part!r} is not an integer RGB component." + ) from exc + values.append(float(max(0, min(255, value))) / 255.0) + + if channels == 1: + return torch.tensor([values[0]], dtype=torch.float32) + if channels == 4: + return torch.tensor([values[0], values[1], values[2], 1.0], dtype=torch.float32) + return torch.tensor(values, dtype=torch.float32) + + +def _apply_crop_and_pad_image( + samples: torch.Tensor, + plan: ResizePlan, + pad_values: torch.Tensor, +) -> torch.Tensor: + """Apply final crop and pad operations to BCHW image samples.""" + + cropped = _crop_samples(samples, plan) + if not plan.has_pad: + return cropped + + values = pad_values.to(device=cropped.device, dtype=cropped.dtype).view(1, -1, 1, 1) + canvas = values.expand( + cropped.shape[0], + cropped.shape[1], + plan.output_height, + plan.output_width, + ).clone() + canvas[ + :, + :, + plan.pad_top : plan.pad_top + cropped.shape[-2], + plan.pad_left : plan.pad_left + cropped.shape[-1], + ] = cropped + return canvas + + +def _apply_crop_and_pad_mask(samples: torch.Tensor, plan: ResizePlan) -> torch.Tensor: + """Apply final crop and pad operations to BCHW mask samples.""" + + cropped = _crop_samples(samples, plan) + if not plan.has_pad: + return cropped + + canvas = torch.zeros( + (cropped.shape[0], 1, plan.output_height, plan.output_width), + dtype=cropped.dtype, + device=cropped.device, + ) + canvas[ + :, + :, + plan.pad_top : plan.pad_top + cropped.shape[-2], + plan.pad_left : plan.pad_left + cropped.shape[-1], + ] = cropped + return canvas + + +def _crop_samples(samples: torch.Tensor, plan: ResizePlan) -> torch.Tensor: + """Crop BCHW samples according to the resize plan.""" + + return samples[ + :, + :, + plan.crop_y : plan.crop_y + plan.output_height, + plan.crop_x : plan.crop_x + plan.output_width, + ] + + +def _chunk_spans(batch_size: int, max_batch_size: int) -> list[tuple[int, int]]: + """Split a batch into inclusive-exclusive chunk spans.""" + + if max_batch_size <= 0 or max_batch_size >= batch_size: + return [(0, batch_size)] + spans: list[tuple[int, int]] = [] + start = 0 + while start < batch_size: + end = min(batch_size, start + max_batch_size) + spans.append((start, end)) + start = end + return spans + + +def _coerce_processor(processor: str) -> Processor: + """Validate and narrow a raw processor string.""" + + if processor == "cpu": + return "cpu" + if processor == "gpu": + return "gpu" + raise ValueError(f"processor must be 'cpu' or 'gpu', got {processor!r}.") diff --git a/simple_syrup/masking/__init__.py b/simple_syrup/masking/__init__.py new file mode 100644 index 0000000..b85162c --- /dev/null +++ b/simple_syrup/masking/__init__.py @@ -0,0 +1,9 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Masking services and pure mask operations for SimpleSyrup.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/simple_syrup/masking/mask_ops.py b/simple_syrup/masking/mask_ops.py new file mode 100644 index 0000000..81f72d5 --- /dev/null +++ b/simple_syrup/masking/mask_ops.py @@ -0,0 +1,279 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Pure mask composition and refinement helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + +import torch +import torch.nn.functional as F + +DETAIL_METHODS = ( + "GuidedFilter", + "PyMatting", + "VITMatte", +) + + +@dataclass(frozen=True) +class MaskRefinementSettings: + """Controls for standalone prompt mask post-processing.""" + + detail_method: str + detail_erode: int + detail_dilate: int + black_point: float + white_point: float + process_detail: bool + execution_device: str + max_size_pixels: int + + +def zero_mask_like_image(image: torch.Tensor) -> torch.Tensor: + """Return an all-zero BHW mask matching a BHWC image tensor.""" + + batch_size, height, width, _channels = image.shape + return torch.zeros( + (int(batch_size), int(height), int(width)), + dtype=torch.float32, + device=image.device, + ) + + +def compose_prompt_masks( + positive_mask: torch.Tensor, + negative_mask: torch.Tensor | None = None, +) -> torch.Tensor: + """Return `positive_mask - negative_mask` clamped to ComfyUI mask range.""" + + positive = positive_mask.float() + if negative_mask is None: + return positive.clamp(0.0, 1.0) + return (positive - negative_mask.float()).clamp(0.0, 1.0) + + +def refine_prompt_mask( + mask: torch.Tensor, + image: torch.Tensor, + settings: MaskRefinementSettings, +) -> torch.Tensor: + """Apply level remapping and optional edge-detail refinement.""" + + _validate_refinement_settings(settings) + output_device = mask.device + work_device = _resolve_refinement_device(settings.execution_device) + working_mask = mask.to(device=work_device, dtype=torch.float32).clamp(0.0, 1.0) + working_image = image.to(device=work_device, dtype=torch.float32).clamp(0.0, 1.0) + + if settings.process_detail: + working_mask = _refine_detail(working_mask, working_image, settings) + + remapped = histogram_remap(working_mask, settings.black_point, settings.white_point) + return remapped.to(device=output_device, dtype=torch.float32).clamp(0.0, 1.0) + + +def histogram_remap( + mask: torch.Tensor, + black_point: float, + white_point: float, +) -> torch.Tensor: + """Remap mask values between black and white points into `[0, 1]`.""" + + if white_point <= black_point: + raise ValueError("white_point must be greater than black_point.") + return ((mask.float() - black_point) / (white_point - black_point)).clamp(0.0, 1.0) + + +def _validate_refinement_settings(settings: MaskRefinementSettings) -> None: + """Reject invalid mask refinement settings.""" + + if settings.detail_method not in DETAIL_METHODS: + valid = ", ".join(DETAIL_METHODS) + raise ValueError(f"detail_method must be one of: {valid}.") + if settings.detail_erode < 0: + raise ValueError("detail_erode must be greater than or equal to 0.") + if settings.detail_dilate < 0: + raise ValueError("detail_dilate must be greater than or equal to 0.") + if not 0.0 <= settings.black_point < settings.white_point <= 1.0: + raise ValueError( + "black_point and white_point must satisfy 0 <= black < white <= 1." + ) + if settings.execution_device not in ("auto", "cpu"): + raise ValueError("execution_device must be 'auto' or 'cpu'.") + if settings.max_size_pixels < 1: + raise ValueError("max_size_pixels must be at least 1.") + + +def _resolve_refinement_device(device: str) -> torch.device: + """Return the requested torch device or fail clearly.""" + + return torch.device("cpu") + + +def _refine_detail( + mask: torch.Tensor, + image: torch.Tensor, + settings: MaskRefinementSettings, +) -> torch.Tensor: + """Refine mask edges using a bounded-size native torch implementation.""" + + if settings.detail_method == "VITMatte": + raise ValueError( + "detail_method 'VITMatte' requires a connected VITMATTE_MODEL when " + "refine_mask is enabled. Connect ViTMatte Model Loader or choose " + "GuidedFilter." + ) + + height = int(mask.shape[-2]) + width = int(mask.shape[-1]) + max_dimension = max(height, width) + if max_dimension > settings.max_size_pixels: + scale = settings.max_size_pixels / float(max_dimension) + scaled_height = max(1, int(round(height * scale))) + scaled_width = max(1, int(round(width * scale))) + scaled_settings = replace( + settings, + detail_erode=max(1, int(round(settings.detail_erode * scale))), + detail_dilate=max(1, int(round(settings.detail_dilate * scale))), + ) + small_mask = _resize_mask(mask, scaled_height, scaled_width) + small_image = _resize_image(image, scaled_height, scaled_width) + refined = _refine_detail_at_size(small_mask, small_image, scaled_settings) + return _resize_mask(refined, height, width) + + return _refine_detail_at_size(mask, image, settings) + + +def _refine_detail_at_size( + mask: torch.Tensor, + image: torch.Tensor, + settings: MaskRefinementSettings, +) -> torch.Tensor: + """Run one detail refinement pass at the current tensor size.""" + + mask_bchw = mask.unsqueeze(1) + edge_band = ( + _dilate(mask_bchw, settings.detail_dilate) + - _erode(mask_bchw, settings.detail_erode) + ).clamp(0.0, 1.0) + + if settings.detail_method == "GuidedFilter": + smoothed = _guided_filter_mask(image, mask, _detail_radius(settings)).unsqueeze( + 1 + ) + else: + smoothed = _box_blur(mask_bchw, _detail_radius(settings)) + + refined = mask_bchw * (1.0 - edge_band) + smoothed * edge_band + return refined.squeeze(1).clamp(0.0, 1.0) + + +def _detail_radius(settings: MaskRefinementSettings) -> int: + """Return a method-specific smoothing radius.""" + + detail_range = settings.detail_erode + settings.detail_dilate + if settings.detail_method == "GuidedFilter": + return max(1, detail_range // 6 + 1) + if settings.detail_method == "PyMatting": + return max(1, detail_range // 8 + 1) + return max(1, detail_range // 4 + 1) + + +def _guided_filter_mask( + image: torch.Tensor, + mask: torch.Tensor, + radius: int, + epsilon: float = 1e-4, +) -> torch.Tensor: + """Apply a grayscale guided filter to a BHW mask using a BHWC image.""" + + guidance = _grayscale_bchw(image) + target = mask.unsqueeze(1) + mean_i = _box_blur(guidance, radius) + mean_p = _box_blur(target, radius) + corr_i = _box_blur(guidance * guidance, radius) + corr_ip = _box_blur(guidance * target, radius) + var_i = corr_i - mean_i * mean_i + cov_ip = corr_ip - mean_i * mean_p + a = cov_ip / (var_i + epsilon) + b = mean_p - a * mean_i + mean_a = _box_blur(a, radius) + mean_b = _box_blur(b, radius) + return (mean_a * guidance + mean_b).squeeze(1).clamp(0.0, 1.0) + + +def _grayscale_bchw(image: torch.Tensor) -> torch.Tensor: + """Convert a BHWC image tensor to B1HW grayscale guidance.""" + + channels = int(image.shape[-1]) + if channels >= 3: + weights = torch.tensor( + [0.299, 0.587, 0.114], + dtype=image.dtype, + device=image.device, + ) + gray = (image[..., :3] * weights).sum(dim=-1) + else: + gray = image[..., 0] + return gray.unsqueeze(1) + + +def _box_blur(samples: torch.Tensor, radius: int) -> torch.Tensor: + """Blur BCHW samples with edge-aware average pooling.""" + + if radius <= 0: + return samples + kernel_size = radius * 2 + 1 + return F.avg_pool2d( + samples, + kernel_size=kernel_size, + stride=1, + padding=radius, + count_include_pad=False, + ) + + +def _dilate(samples: torch.Tensor, radius: int) -> torch.Tensor: + """Dilate BCHW mask samples.""" + + if radius <= 0: + return samples + kernel_size = radius * 2 + 1 + padded = F.pad(samples, (radius, radius, radius, radius), value=0.0) + return F.max_pool2d(padded, kernel_size=kernel_size, stride=1) + + +def _erode(samples: torch.Tensor, radius: int) -> torch.Tensor: + """Erode BCHW mask samples.""" + + if radius <= 0: + return samples + kernel_size = radius * 2 + 1 + padded = F.pad(samples, (radius, radius, radius, radius), value=0.0) + return -F.max_pool2d(-padded, kernel_size=kernel_size, stride=1) + + +def _resize_mask(mask: torch.Tensor, height: int, width: int) -> torch.Tensor: + """Resize a BHW mask tensor.""" + + return F.interpolate( + mask.unsqueeze(1), + size=(height, width), + mode="bilinear", + align_corners=False, + ).squeeze(1) + + +def _resize_image(image: torch.Tensor, height: int, width: int) -> torch.Tensor: + """Resize a BHWC image tensor.""" + + resized = F.interpolate( + image.movedim(-1, 1), + size=(height, width), + mode="bilinear", + align_corners=False, + ) + return resized.movedim(1, -1) diff --git a/simple_syrup/masking/prompt_segs_with_sam_service.py b/simple_syrup/masking/prompt_segs_with_sam_service.py new file mode 100644 index 0000000..4914f3e --- /dev/null +++ b/simple_syrup/masking/prompt_segs_with_sam_service.py @@ -0,0 +1,402 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Application service for prompt-based SAM SEGS construction.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import torch + +from ..domain.segs import ( + BoundingBox, + NativeSegs, + Segment, + sort_segs, +) +from ..masking.mask_ops import MaskRefinementSettings, refine_prompt_mask +from ..masking.segs_mask_ops import ( + crop_image, + crop_mask, + crop_region_for_bbox, + dilate_mask, + normalize_mask, + validate_single_image, +) +from ..runtime.sam_segmenter import SAMBoxSegmenter, SAMModelSegmenter +from ..runtime.text_box_detector import ( + GroundingDINOTextBoxDetector, + TextBoxDetection, + TextBoxDetector, +) +from ..runtime.vitmatte_refiner import MaskDetailRefiner, ViTMatteRefiner + + +@dataclass(frozen=True) +class PromptSegsRuntime: + """Runtime dependencies used to detect prompt boxes and segment masks.""" + + detector: TextBoxDetector + segmenter: SAMBoxSegmenter + + +@dataclass(frozen=True) +class PromptSegsSettings: + """Validated controls for prompt-to-SEGS construction.""" + + positive_prompt: str + negative_prompt: str + confidence_threshold: float + size_threshold: int + bbox_dilation: int + mask_dilation: int + crop_factor: float + sort_order: str + refinement: MaskRefinementSettings + + +class PromptSEGSWithSAMService: + """Validate inputs and build prompt-derived SEGS with SAM masks.""" + + def __init__( + self, + runtime: PromptSegsRuntime | None = None, + vitmatte_refiner: MaskDetailRefiner | None = None, + ) -> None: + """Create the service with injectable runtime dependencies.""" + + self._runtime = runtime or PromptSegsRuntime( + detector=GroundingDINOTextBoxDetector(), + segmenter=SAMModelSegmenter(), + ) + self._vitmatte_refiner = vitmatte_refiner or ViTMatteRefiner() + + def prompt( + self, + image: object, + sam_model: object, + grounding_dino_model: object, + vitmatte_model: object | None, + positive_prompt: str, + negative_prompt: str, + confidence_threshold: float, + size_threshold: int, + bbox_dilation: int, + mask_dilation: int, + detail_method: str, + detail_erode: int, + detail_dilate: int, + black_point: float, + white_point: float, + refine_mask: bool, + mask_refinement_max_size: int, + execution_device: str, + crop_factor: float, + sort_order: str, + ) -> NativeSegs: + """Return sorted native SEGS for a text-prompted SAM detection.""" + + image_tensor = validate_single_image(image, "Prompt SEGS w/ SAM") + settings = self._validate_settings( + positive_prompt=positive_prompt, + negative_prompt=negative_prompt, + confidence_threshold=confidence_threshold, + size_threshold=size_threshold, + bbox_dilation=bbox_dilation, + mask_dilation=mask_dilation, + detail_method=detail_method, + detail_erode=detail_erode, + detail_dilate=detail_dilate, + black_point=black_point, + white_point=white_point, + refine_mask=refine_mask, + mask_refinement_max_size=mask_refinement_max_size, + execution_device=execution_device, + crop_factor=crop_factor, + sort_order=sort_order, + ) + + sample = image_tensor[0] + height = int(sample.shape[0]) + width = int(sample.shape[1]) + positive_regions = self._prompt_regions( + image=sample, + sam_model=sam_model, + grounding_dino_model=grounding_dino_model, + prompt=settings.positive_prompt, + settings=settings, + execution_device=execution_device, + ) + negative_mask = self._negative_mask( + image=sample, + sam_model=sam_model, + grounding_dino_model=grounding_dino_model, + settings=settings, + execution_device=execution_device, + height=height, + width=width, + ) + + segments: list[Segment] = [] + for detection, mask in positive_regions: + final_mask = (mask - negative_mask).clamp(0.0, 1.0) + final_mask = dilate_mask(final_mask, settings.mask_dilation) + final_mask = self._refine_mask( + image_tensor=image_tensor, + mask=final_mask, + settings=settings, + vitmatte_model=vitmatte_model, + ) + bbox = _bbox_from_mask(final_mask) + if bbox is None: + continue + if ( + bbox.width < settings.size_threshold + or bbox.height < settings.size_threshold + ): + continue + crop_region = crop_region_for_bbox( + bbox, + image_height=height, + image_width=width, + crop_factor=settings.crop_factor, + ) + segments.append( + Segment( + cropped_image=crop_image(image_tensor, crop_region) + .detach() + .clone(), + cropped_mask=crop_mask(final_mask, crop_region).detach().clone(), + confidence=detection.confidence, + crop_region=crop_region, + bbox=bbox, + label=settings.positive_prompt, + ) + ) + + return sort_segs(((height, width), tuple(segments)), settings.sort_order) + + def _prompt_regions( + self, + image: torch.Tensor, + sam_model: object, + grounding_dino_model: object, + prompt: str, + settings: PromptSegsSettings, + execution_device: str, + ) -> tuple[tuple[TextBoxDetection, torch.Tensor], ...]: + """Return detected prompt boxes with their SAM masks.""" + + detections = self._runtime.detector.detect( + grounding_dino_model, + image, + prompt, + settings.confidence_threshold, + execution_device, + ) + if not detections: + return () + + height = int(image.shape[0]) + width = int(image.shape[1]) + expanded_boxes = tuple( + _expand_bbox(detection.bbox, settings.bbox_dilation, height, width) + for detection in detections + ) + masks = self._runtime.segmenter.segment_boxes( + sam_model, + image, + _boxes_tensor(expanded_boxes), + settings.confidence_threshold, + execution_device, + ) + if len(masks) != len(detections): + raise ValueError( + "Prompt SEGS w/ SAM runtime returned a mask count that does not " + "match prompt detections." + ) + return tuple( + ( + detection, + normalize_mask(mask, height, width), + ) + for detection, mask in zip(detections, masks, strict=True) + ) + + def _negative_mask( + self, + image: torch.Tensor, + sam_model: object, + grounding_dino_model: object, + settings: PromptSegsSettings, + execution_device: str, + height: int, + width: int, + ) -> torch.Tensor: + """Return one combined negative prompt mask.""" + + if not settings.negative_prompt: + return torch.zeros((height, width), dtype=torch.float32) + + negative_regions = self._prompt_regions( + image=image, + sam_model=sam_model, + grounding_dino_model=grounding_dino_model, + prompt=settings.negative_prompt, + settings=settings, + execution_device=execution_device, + ) + if not negative_regions: + return torch.zeros((height, width), dtype=torch.float32) + return torch.stack([mask for _detection, mask in negative_regions]).amax(dim=0) + + def _refine_mask( + self, + image_tensor: torch.Tensor, + mask: torch.Tensor, + settings: PromptSegsSettings, + vitmatte_model: object | None, + ) -> torch.Tensor: + """Apply configured mask refinement to one full-image mask.""" + + mask_batch = mask.unsqueeze(0) + if ( + settings.refinement.process_detail + and settings.refinement.detail_method == "VITMatte" + ): + vitmatte_refined = self._vitmatte_refiner.refine( + image_tensor, + mask_batch, + settings.refinement, + vitmatte_model, + ) + remap_settings = MaskRefinementSettings( + detail_method="GuidedFilter", + detail_erode=0, + detail_dilate=0, + black_point=settings.refinement.black_point, + white_point=settings.refinement.white_point, + process_detail=False, + execution_device=settings.refinement.execution_device, + max_size_pixels=settings.refinement.max_size_pixels, + ) + return refine_prompt_mask( + vitmatte_refined, + image_tensor, + remap_settings, + )[0] + + return refine_prompt_mask(mask_batch, image_tensor, settings.refinement)[0] + + def _validate_settings( + self, + positive_prompt: str, + negative_prompt: str, + confidence_threshold: float, + size_threshold: int, + bbox_dilation: int, + mask_dilation: int, + detail_method: str, + detail_erode: int, + detail_dilate: int, + black_point: float, + white_point: float, + refine_mask: bool, + mask_refinement_max_size: int, + execution_device: str, + crop_factor: float, + sort_order: str, + ) -> PromptSegsSettings: + """Validate public node settings and return normalized values.""" + + positive_text = positive_prompt.strip() + if not positive_text: + raise ValueError("positive_prompt is required for Prompt SEGS w/ SAM.") + if not 0.0 <= confidence_threshold <= 1.0: + raise ValueError("confidence_threshold must be between 0 and 1.") + if size_threshold < 1: + raise ValueError("size_threshold must be at least 1.") + if crop_factor < 1.0: + raise ValueError("crop_factor must be at least 1.0.") + if mask_refinement_max_size < 1: + raise ValueError("mask_refinement_max_size must be at least 1.") + + refinement = MaskRefinementSettings( + detail_method=detail_method, + detail_erode=int(detail_erode), + detail_dilate=int(detail_dilate), + black_point=float(black_point), + white_point=float(white_point), + process_detail=bool(refine_mask), + execution_device=execution_device, + max_size_pixels=int(mask_refinement_max_size), + ) + refine_prompt_mask( + torch.zeros((1, 1, 1), dtype=torch.float32), + torch.zeros((1, 1, 1, 3), dtype=torch.float32), + MaskRefinementSettings( + detail_method=refinement.detail_method, + detail_erode=refinement.detail_erode, + detail_dilate=refinement.detail_dilate, + black_point=refinement.black_point, + white_point=refinement.white_point, + process_detail=False, + execution_device=refinement.execution_device, + max_size_pixels=refinement.max_size_pixels, + ), + ) + + return PromptSegsSettings( + positive_prompt=positive_text, + negative_prompt=negative_prompt.strip(), + confidence_threshold=float(confidence_threshold), + size_threshold=int(size_threshold), + bbox_dilation=int(bbox_dilation), + mask_dilation=int(mask_dilation), + crop_factor=float(crop_factor), + sort_order=sort_order, + refinement=refinement, + ) + + +def _expand_bbox( + bbox: BoundingBox, + dilation: int, + image_height: int, + image_width: int, +) -> BoundingBox: + """Expand a bbox by a signed pixel radius and clamp it to image bounds.""" + + left = max(0, min(image_width, bbox.left - dilation)) + top = max(0, min(image_height, bbox.top - dilation)) + right = max(0, min(image_width, bbox.right + dilation)) + bottom = max(0, min(image_height, bbox.bottom + dilation)) + if right <= left or bottom <= top: + return bbox + return BoundingBox(left, top, right, bottom) + + +def _boxes_tensor(boxes: tuple[BoundingBox, ...]) -> torch.Tensor: + """Convert bbox values to an XYXY tensor for SAM runtimes.""" + + if not boxes: + return torch.empty((0, 4), dtype=torch.float32) + return torch.tensor( + [[box.left, box.top, box.right, box.bottom] for box in boxes], + dtype=torch.float32, + ) + + +def _bbox_from_mask(mask: torch.Tensor) -> BoundingBox | None: + """Return the tight bbox around a nonzero HW mask.""" + + y_coords, x_coords = torch.where(mask > 0) + if y_coords.numel() == 0 or x_coords.numel() == 0: + return None + left = int(torch.min(x_coords).item()) + top = int(torch.min(y_coords).item()) + right = int(torch.max(x_coords).item()) + 1 + bottom = int(torch.max(y_coords).item()) + 1 + return BoundingBox(left, top, right, bottom) diff --git a/simple_syrup/masking/regional_detailing_masks.py b/simple_syrup/masking/regional_detailing_masks.py new file mode 100644 index 0000000..b7cc86f --- /dev/null +++ b/simple_syrup/masking/regional_detailing_masks.py @@ -0,0 +1,263 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Portions of this file incorporate behavior derived from +# multidiffusion-upscaler-for-automatic1111. See third_party/manifest.toml and +# third_party/NOTICE.md. + +"""Mask conversion helpers for regional SEGS detailing.""" + +from __future__ import annotations + +import math + +import torch + +from ..domain.regional_detailing import ( + ImageRegion, + LatentBox, + LatentRegion, + SegmentConditioningPair, +) +from ..domain.segs import CropRegion +from .segs_mask_ops import feather_mask, resize_mask + +OPERATION = "Detail SEGS as Regions" + + +def build_image_regions( + pairs: tuple[SegmentConditioningPair, ...], + *, + image_height: int, + image_width: int, +) -> tuple[ImageRegion, ...]: + """Create full-image masks from paired SEGS crop-local masks.""" + + return tuple( + _image_region_from_pair( + pair, + image_height=image_height, + image_width=image_width, + ) + for pair in pairs + ) + + +def build_latent_regions( + image_regions: tuple[ImageRegion, ...], + *, + latent_height: int, + latent_width: int, + device: torch.device, + dtype: torch.dtype, + latent_feather: int, +) -> tuple[LatentRegion, ...]: + """Convert image-space regions into latent-space masks and boxes.""" + + if latent_height < 1 or latent_width < 1: + raise ValueError("latent dimensions must be positive.") + latent_regions: list[LatentRegion] = [] + for region in image_regions: + latent_mask = resize_mask(region.image_mask, latent_height, latent_width) + if latent_feather > 0: + latent_mask = feather_mask(latent_mask, latent_feather) + latent_mask = latent_mask.to(device=device, dtype=dtype).clamp(0.0, 1.0) + latent_box = latent_box_from_mask( + latent_mask, + region_index=region.index, + label=region.label, + ) + latent_regions.append( + LatentRegion( + index=region.index, + label=region.label, + latent_box=latent_box, + latent_mask=latent_mask, + positive=region.positive, + ) + ) + return tuple(latent_regions) + + +def scale_image_regions( + image_regions: tuple[ImageRegion, ...], + *, + image_height: int, + image_width: int, +) -> tuple[ImageRegion, ...]: + """Resize full-image region masks into a scaled image coordinate space.""" + + if image_height < 1 or image_width < 1: + raise ValueError("scaled image dimensions must be positive.") + return tuple( + ImageRegion( + index=region.index, + label=region.label, + crop_region=_scale_crop_region( + region.crop_region, + source_height=int(region.image_mask.shape[0]), + source_width=int(region.image_mask.shape[1]), + target_height=image_height, + target_width=image_width, + ), + image_mask=resize_mask(region.image_mask, image_height, image_width), + positive=region.positive, + ) + for region in image_regions + ) + + +def latent_box_from_mask( + mask: torch.Tensor, + *, + region_index: int, + label: str, +) -> LatentBox: + """Return the tight latent box around a non-empty HW mask.""" + + if mask.ndim != 2: + raise ValueError("latent region mask must be HW shaped.") + coordinates = torch.nonzero(mask > 0, as_tuple=False) + if coordinates.numel() == 0: + raise ValueError( + f"{OPERATION} SEG {region_index} ('{label}') produced an empty " + "latent region." + ) + top = int(coordinates[:, 0].min().item()) + bottom = int(coordinates[:, 0].max().item()) + 1 + left = int(coordinates[:, 1].min().item()) + right = int(coordinates[:, 1].max().item()) + 1 + return LatentBox( + x=left, + y=top, + width=right - left, + height=bottom - top, + ) + + +def union_masks(masks: tuple[torch.Tensor, ...]) -> torch.Tensor: + """Return the clamped union of one or more same-shaped HW masks.""" + + if not masks: + raise ValueError("at least one mask is required.") + shape = masks[0].shape + if any(mask.shape != shape for mask in masks): + raise ValueError("all masks must have the same shape.") + union = torch.zeros_like(masks[0], dtype=torch.float32) + for mask in masks: + union = torch.maximum(union, mask.float()) + return union.clamp(0.0, 1.0) + + +def feather_image_mask(mask: torch.Tensor, feather: int) -> torch.Tensor: + """Feather an image-space mask while preserving the HW contract.""" + + return feather_mask(mask, feather) + + +def proportional_latent_box( + *, + left: int, + top: int, + right: int, + bottom: int, + image_height: int, + image_width: int, + latent_height: int, + latent_width: int, +) -> LatentBox: + """Convert an image-space box to a clamped latent-space box.""" + + if image_height < 1 or image_width < 1: + raise ValueError("image dimensions must be positive.") + x = max(0, min(latent_width - 1, math.floor(left * latent_width / image_width))) + y = max(0, min(latent_height - 1, math.floor(top * latent_height / image_height))) + x2 = max(x + 1, min(latent_width, math.ceil(right * latent_width / image_width))) + y2 = max( + y + 1, min(latent_height, math.ceil(bottom * latent_height / image_height)) + ) + return LatentBox(x=x, y=y, width=x2 - x, height=y2 - y) + + +def _scale_crop_region( + region: CropRegion, + *, + source_height: int, + source_width: int, + target_height: int, + target_width: int, +) -> CropRegion: + """Scale a crop region proportionally into a target image shape.""" + + if source_height < 1 or source_width < 1: + raise ValueError("source image dimensions must be positive.") + left = max( + 0, min(target_width - 1, math.floor(region.left * target_width / source_width)) + ) + top = max( + 0, + min(target_height - 1, math.floor(region.top * target_height / source_height)), + ) + right = max( + left + 1, + min(target_width, math.ceil(region.right * target_width / source_width)), + ) + bottom = max( + top + 1, + min(target_height, math.ceil(region.bottom * target_height / source_height)), + ) + return CropRegion(left, top, right, bottom) + + +def _image_region_from_pair( + pair: SegmentConditioningPair, + *, + image_height: int, + image_width: int, +) -> ImageRegion: + """Paste one pair's crop-local mask into full-image mask space.""" + + crop_mask = _crop_mask_to_hw( + pair.segment.cropped_mask, + height=pair.segment.crop_region.height, + width=pair.segment.crop_region.width, + index=pair.index, + label=pair.segment.label, + ) + full_mask = torch.zeros((image_height, image_width), dtype=torch.float32) + region = pair.segment.crop_region + full_mask[region.top : region.bottom, region.left : region.right] = crop_mask + return ImageRegion( + index=pair.index, + label=pair.segment.label, + crop_region=region, + image_mask=full_mask.clamp(0.0, 1.0), + positive=pair.positive, + ) + + +def _crop_mask_to_hw( + mask: object, + *, + height: int, + width: int, + index: int, + label: str, +) -> torch.Tensor: + """Return a crop-local HW mask resized to the segment crop region.""" + + mask_tensor = torch.as_tensor(mask).float() + if mask_tensor.ndim == 3 and int(mask_tensor.shape[0]) == 1: + mask_tensor = mask_tensor[0] + if mask_tensor.ndim != 2: + raise ValueError( + f"{OPERATION} SEG {index} ('{label}') cropped_mask must be HW or " + "single-batch BHW shaped." + ) + resized = resize_mask(mask_tensor, height, width) + if not torch.any(resized > 0): + raise ValueError( + f"{OPERATION} SEG {index} ('{label}') produced an empty image region." + ) + return resized.float().clamp(0.0, 1.0) diff --git a/simple_syrup/masking/segs_mask_ops.py b/simple_syrup/masking/segs_mask_ops.py new file mode 100644 index 0000000..e495527 --- /dev/null +++ b/simple_syrup/masking/segs_mask_ops.py @@ -0,0 +1,197 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Mask and crop helpers for SEGS detection and detailing.""" + +from __future__ import annotations + +import torch +import torch.nn.functional as F + +from ..domain.segs import BoundingBox, CropRegion + + +def validate_single_image(image: object, operation: str) -> torch.Tensor: + """Return a validated single-image BHWC tensor.""" + + image_batch = validate_image_batch(image, operation) + batch_size = int(image_batch.shape[0]) + if batch_size != 1: + raise ValueError( + f"{operation} currently supports one image at a time; " + f"received batch size {batch_size}." + ) + return image_batch + + +def validate_image_batch(image: object, operation: str) -> torch.Tensor: + """Return a validated BHWC image batch tensor.""" + + if not isinstance(image, torch.Tensor): + raise TypeError(f"{operation} requires a torch IMAGE tensor.") + if image.ndim != 4: + raise ValueError(f"{operation} requires a BHWC IMAGE tensor.") + batch_size = int(image.shape[0]) + if batch_size < 1: + raise ValueError(f"{operation} requires at least one image.") + channels = int(image.shape[-1]) + if channels < 1: + raise ValueError(f"{operation} requires at least one image channel.") + return image.float().clamp(0.0, 1.0) + + +def iter_single_images(image_batch: torch.Tensor) -> tuple[torch.Tensor, ...]: + """Return one-image BHWC slices from a validated image batch.""" + + return tuple( + image_batch[index : index + 1] for index in range(int(image_batch.shape[0])) + ) + + +def rectangular_mask(height: int, width: int, bbox: BoundingBox) -> torch.Tensor: + """Create a full-image mask filled inside a bounding box.""" + + mask = torch.zeros((height, width), dtype=torch.float32) + mask[bbox.top : bbox.bottom, bbox.left : bbox.right] = 1.0 + return mask + + +def normalize_mask(mask: torch.Tensor, height: int, width: int) -> torch.Tensor: + """Normalize a mask to an unbatched HW tensor in float range.""" + + working = mask.float() + if working.ndim == 3: + working = working[0] + if working.ndim != 2: + raise ValueError("Detection mask must be HW or BHW shaped.") + if int(working.shape[0]) != height or int(working.shape[1]) != width: + working = ( + F.interpolate( + working.unsqueeze(0).unsqueeze(0), + size=(height, width), + mode="bilinear", + align_corners=False, + ) + .squeeze(0) + .squeeze(0) + ) + return working.clamp(0.0, 1.0) + + +def dilate_mask(mask: torch.Tensor, dilation: int) -> torch.Tensor: + """Morph an HW mask by a signed pixel radius.""" + + if dilation == 0: + return mask.float().clamp(0.0, 1.0) + radius = abs(dilation) + kernel_size = radius * 2 + 1 + padded = F.pad( + mask.float().unsqueeze(0).unsqueeze(0), + (radius, radius, radius, radius), + value=0.0, + ) + if dilation > 0: + return ( + F.max_pool2d(padded, kernel_size=kernel_size, stride=1) + .squeeze(0) + .squeeze(0) + ) + return ( + (-F.max_pool2d(-padded, kernel_size=kernel_size, stride=1)) + .squeeze(0) + .squeeze(0) + ) + + +def feather_mask(mask: torch.Tensor, radius: int) -> torch.Tensor: + """Soften an HW mask edge by a pixel radius.""" + + if radius < 0: + raise ValueError("feather radius must be greater than or equal to 0.") + if radius == 0: + return mask.float().clamp(0.0, 1.0) + kernel_size = radius * 2 + 1 + blurred = ( + F.avg_pool2d( + mask.float().unsqueeze(0).unsqueeze(0), + kernel_size=kernel_size, + stride=1, + padding=radius, + count_include_pad=False, + ) + .squeeze(0) + .squeeze(0) + ) + return blurred.clamp(0.0, 1.0) + + +def crop_region_for_bbox( + bbox: BoundingBox, + image_height: int, + image_width: int, + crop_factor: float, +) -> CropRegion: + """Expand a bbox around its center or select the full image for zero.""" + + if crop_factor == 0.0: + return CropRegion(0, 0, image_width, image_height) + if crop_factor < 1.0: + raise ValueError("crop_factor must be 0 or greater than or equal to 1.0.") + center_x = (bbox.left + bbox.right) / 2.0 + center_y = (bbox.top + bbox.bottom) / 2.0 + crop_width = bbox.width * crop_factor + crop_height = bbox.height * crop_factor + left = max(0, int(round(center_x - crop_width / 2.0))) + top = max(0, int(round(center_y - crop_height / 2.0))) + right = min(image_width, int(round(center_x + crop_width / 2.0))) + bottom = min(image_height, int(round(center_y + crop_height / 2.0))) + if right <= left: + right = min(image_width, left + 1) + if bottom <= top: + bottom = min(image_height, top + 1) + return CropRegion(left, top, right, bottom) + + +def crop_image(image: torch.Tensor, region: CropRegion) -> torch.Tensor: + """Crop a BHWC image tensor by a crop region.""" + + return image[:, region.top : region.bottom, region.left : region.right, :] + + +def crop_mask(mask: torch.Tensor, region: CropRegion) -> torch.Tensor: + """Crop an HW mask tensor by a crop region.""" + + return mask[region.top : region.bottom, region.left : region.right] + + +def resize_image(image: torch.Tensor, height: int, width: int) -> torch.Tensor: + """Resize a BHWC image tensor with bilinear interpolation.""" + + resized = F.interpolate( + image.movedim(-1, 1), + size=(height, width), + mode="bilinear", + align_corners=False, + ) + return resized.movedim(1, -1).clamp(0.0, 1.0) + + +def resize_mask(mask: torch.Tensor, height: int, width: int) -> torch.Tensor: + """Resize an HW or BHW mask tensor with bilinear interpolation.""" + + if mask.ndim == 2: + working = mask.unsqueeze(0) + elif mask.ndim == 3: + working = mask + else: + raise ValueError("Mask must be HW or BHW shaped.") + resized = F.interpolate( + working.unsqueeze(1).float(), + size=(height, width), + mode="bilinear", + align_corners=False, + ).squeeze(1) + if mask.ndim == 2: + return resized.squeeze(0).clamp(0.0, 1.0) + return resized.clamp(0.0, 1.0) diff --git a/simple_syrup/nodes/__init__.py b/simple_syrup/nodes/__init__.py new file mode 100644 index 0000000..e9fe747 --- /dev/null +++ b/simple_syrup/nodes/__init__.py @@ -0,0 +1,137 @@ +# 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 registration for SimpleSyrup.""" + +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", +] diff --git a/simple_syrup/nodes/conditioning_batch_pack.py b/simple_syrup/nodes/conditioning_batch_pack.py new file mode 100644 index 0000000..ea037ea --- /dev/null +++ b/simple_syrup/nodes/conditioning_batch_pack.py @@ -0,0 +1,88 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI nodes for constructing SimpleSyrup conditioning batches.""" + +from __future__ import annotations + +from typing import Any + +from ..domain.conditioning_batch import ConditioningBatch + + +class ConditioningBatchStart: + """Start a conditioning batch from one normal conditioning value.""" + + RETURN_TYPES = ("CONDITIONING_BATCH",) + RETURN_NAMES = ("batch",) + OUTPUT_TOOLTIPS = ( + "Conditioning batch with one entry, ready to align with the first SEGS item.", + ) + FUNCTION = "pack" + CATEGORY = "SimpleSyrup/Conditioning" + DESCRIPTION = "Starts a per-segment conditioning batch." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare the first conditioning input.""" + + return { + "required": { + "conditioning": ( + "CONDITIONING", + { + "tooltip": ( + "Conditioning for the first SEGS item in a per-region " + "batch." + ) + }, + ), + } + } + + def pack(self, conditioning: Any) -> tuple[ConditioningBatch]: + """Return a new conditioning batch with one entry.""" + + return (ConditioningBatch((conditioning,)),) + + +class ConditioningBatchAppend: + """Append one normal conditioning value to an existing batch.""" + + RETURN_TYPES = ("CONDITIONING_BATCH",) + RETURN_NAMES = ("batch",) + OUTPUT_TOOLTIPS = ("Conditioning batch with the new entry added at the end.",) + FUNCTION = "append" + CATEGORY = "SimpleSyrup/Conditioning" + DESCRIPTION = "Appends a conditioning entry to a per-segment batch." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare the existing batch and conditioning inputs.""" + + return { + "required": { + "batch": ( + "CONDITIONING_BATCH", + {"tooltip": ("Existing per-region batch to extend in SEGS order.")}, + ), + "conditioning": ( + "CONDITIONING", + { + "tooltip": ( + "Conditioning to add as the next per-region batch entry." + ) + }, + ), + } + } + + def append( + self, + batch: ConditioningBatch, + conditioning: Any, + ) -> tuple[ConditioningBatch]: + """Return a new batch with one additional conditioning entry.""" + + return (batch.append(conditioning),) diff --git a/simple_syrup/nodes/detail_segs_as_regions.py b/simple_syrup/nodes/detail_segs_as_regions.py new file mode 100644 index 0000000..af869d8 --- /dev/null +++ b/simple_syrup/nodes/detail_segs_as_regions.py @@ -0,0 +1,275 @@ +# 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 regional MultiDiffusion SEGS detailing.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import torch + +from ..domain.segs import coerce_segs_group +from ..nodes import tooltips +from ..nodes.detailer_input_adapters import ( + bool_input, + conditioning_batch_group, + float_input, + image_inputs, + int_input, + single_input, + str_input, + validate_image_segs_pairing, +) +from ..runtime import sampling_samplers, sampling_schedulers +from ..runtime.detail_resize import SUPPORTED_DETAIL_UPSCALE_METHODS +from ..services.detail_segs_as_regions_service import ( + DetailSEGSAsRegionsService, +) +from .scale_factor import scale_factor_options + +OPERATION = "Detail SEGS as Regions" + + +class DetailSEGSAsRegions: + """Detail SEGS in one regional MultiDiffusion sampling pass.""" + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + INPUT_IS_LIST = True + OUTPUT_TOOLTIPS = (tooltips.DETAIL_IMAGE_OUTPUT,) + FUNCTION = "detail" + CATEGORY = "SimpleSyrup/Detailing" + DESCRIPTION = "Details SEGS regions with paired regional conditioning." + SEARCH_ALIASES = [ + "detailer", + "segs detailer", + "regional detailer", + "multidiffusion detailer", + "tiled diffusion detailer", + "tile and tag", + ] + + service_class: ClassVar[type[DetailSEGSAsRegionsService]] = ( + DetailSEGSAsRegionsService + ) + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare ComfyUI inputs for regional SEGS detailing.""" + + return { + "required": { + "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}, + ), + "segs": ("SEGS", {"tooltip": tooltips.DETAIL_SEGS}), + "region_positive": ( + "CONDITIONING_BATCH", + {"tooltip": tooltips.REGIONAL_POSITIVE_BATCH}, + ), + "global_prompt_weight": ( + "FLOAT", + { + "default": 0.25, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": ( + "Global positive prediction weight inside covered " + "regions; the remaining weight goes to regional prompts." + ), + }, + ), + "scale_factor": ( + "FLOAT", + scale_factor_options(default=1.0), + ), + "upscale_method": ( + list(SUPPORTED_DETAIL_UPSCALE_METHODS), + { + "default": "lanczos", + "tooltip": tooltips.DETAIL_UPSCALE_METHOD, + }, + ), + "seed": ( + "INT", + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": tooltips.SAMPLING_SEED, + }, + ), + "steps": ( + "INT", + { + "default": 20, + "min": 1, + "max": 10000, + "tooltip": tooltips.SAMPLING_STEPS, + }, + ), + "cfg": ( + "FLOAT", + { + "default": 8.0, + "min": 0.0, + "max": 100.0, + "step": 0.1, + "tooltip": tooltips.SAMPLING_CFG, + }, + ), + "sampler_name": ( + sampling_samplers.available_samplers(), + {"tooltip": tooltips.SAMPLER_NAME}, + ), + "scheduler": ( + sampling_schedulers.available_schedulers(), + {"tooltip": tooltips.SCHEDULER}, + ), + "denoise": ( + "FLOAT", + { + "default": 0.5, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": tooltips.DENOISE_STRENGTH, + }, + ), + "feather": ( + "INT", + { + "default": 5, + "min": 0, + "max": 512, + "tooltip": tooltips.DETAIL_FEATHER, + }, + ), + "noise_mask": ( + "BOOLEAN", + { + "default": True, + "tooltip": tooltips.DETAIL_NOISE_MASK, + }, + ), + "noise_mask_feather": ( + "INT", + { + "default": 20, + "min": 0, + "max": 512, + "tooltip": tooltips.DETAIL_NOISE_MASK_FEATHER, + }, + ), + "tiled_encode": ( + "BOOLEAN", + { + "default": False, + "tooltip": tooltips.DETAIL_TILED_ENCODE, + }, + ), + "tiled_decode": ( + "BOOLEAN", + { + "default": False, + "tooltip": tooltips.DETAIL_TILED_DECODE, + }, + ), + } + } + + def detail( + self, + 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, + ) -> tuple[object]: + """Run regional detailing and return the detailed image.""" + + list_mode = isinstance(image, list) + images = image_inputs(image, OPERATION) + segs_group = coerce_segs_group(segs) + validate_image_segs_pairing(images, segs_group, OPERATION) + region_positive_group = conditioning_batch_group( + region_positive, + len(segs_group), + OPERATION, + ) + + service = self.service_class() + outputs: list[torch.Tensor] = [] + for single_image, single_segs, single_region_positive in zip( + images, + segs_group, + region_positive_group, + strict=True, + ): + result = service.detail( + image=single_image, + segs=single_segs, + model=single_input(model, "model", list_mode, OPERATION), + vae=single_input(vae, "vae", list_mode, OPERATION), + positive=single_input(positive, "positive", list_mode, OPERATION), + negative=single_input(negative, "negative", list_mode, OPERATION), + region_positive=single_region_positive, + global_prompt_weight=float_input( + global_prompt_weight, "global_prompt_weight", list_mode, OPERATION + ), + scale_factor=float_input( + scale_factor, "scale_factor", list_mode, OPERATION + ), + upscale_method=str_input( + upscale_method, "upscale_method", list_mode, OPERATION + ), + seed=int_input(seed, "seed", list_mode, OPERATION), + steps=int_input(steps, "steps", list_mode, OPERATION), + cfg=float_input(cfg, "cfg", list_mode, OPERATION), + sampler_name=str_input( + sampler_name, "sampler_name", list_mode, OPERATION + ), + scheduler=str_input(scheduler, "scheduler", list_mode, OPERATION), + denoise=float_input(denoise, "denoise", list_mode, OPERATION), + feather=int_input(feather, "feather", list_mode, OPERATION), + noise_mask=bool_input(noise_mask, "noise_mask", list_mode, OPERATION), + noise_mask_feather=int_input( + noise_mask_feather, "noise_mask_feather", list_mode, OPERATION + ), + tiled_encode=bool_input( + tiled_encode, "tiled_encode", list_mode, OPERATION + ), + tiled_decode=bool_input( + tiled_decode, "tiled_decode", list_mode, OPERATION + ), + ) + outputs.append(result.image) + return (torch.cat(outputs, dim=0),) diff --git a/simple_syrup/nodes/detail_segs_by_scale_factor.py b/simple_syrup/nodes/detail_segs_by_scale_factor.py new file mode 100644 index 0000000..144f9fc --- /dev/null +++ b/simple_syrup/nodes/detail_segs_by_scale_factor.py @@ -0,0 +1,245 @@ +# 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 SEGS detailing by scale factor.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import torch + +from ..domain.segs import coerce_segs_group +from ..nodes import tooltips +from ..nodes.detailer_input_adapters import ( + bool_input, + float_input, + image_inputs, + int_input, + single_input, + str_input, + validate_image_segs_pairing, +) +from ..runtime import sampling_samplers, sampling_schedulers +from ..runtime.detail_resize import SUPPORTED_DETAIL_UPSCALE_METHODS +from ..services.detail_segs_by_scale_factor_service import ( + DetailSEGSByScaleFactorService, +) +from .scale_factor import scale_factor_options + +OPERATION = "Detail SEGS by Scale Factor" + + +class DetailSEGSByScaleFactor: + """Detail provided SEGS by scaling crops before inpaint sampling.""" + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + INPUT_IS_LIST = True + OUTPUT_TOOLTIPS = (tooltips.DETAIL_IMAGE_OUTPUT,) + FUNCTION = "detail" + CATEGORY = "SimpleSyrup/Detailing" + DESCRIPTION = "Details SEGS regions using crop scale factor sizing." + SEARCH_ALIASES = ["detailer", "automask", "segs", "scale factor"] + + service_class: ClassVar[type[DetailSEGSByScaleFactorService]] = ( + DetailSEGSByScaleFactorService + ) + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare ComfyUI inputs for scale-factor detailing.""" + + return { + "required": { + "image": ("IMAGE", {"tooltip": tooltips.DETAIL_IMAGE}), + "segs": ("SEGS", {"tooltip": tooltips.DETAIL_SEGS}), + "model": ("MODEL", {"tooltip": tooltips.DETAIL_MODEL}), + "vae": ("VAE", {"tooltip": tooltips.DETAIL_VAE}), + "positive": ( + "CONDITIONING,CONDITIONING_BATCH", + {"tooltip": tooltips.DETAIL_POSITIVE}, + ), + "negative": ( + "CONDITIONING,CONDITIONING_BATCH", + {"tooltip": tooltips.DETAIL_NEGATIVE}, + ), + "scale_factor": ( + "FLOAT", + scale_factor_options(default=1.5), + ), + "upscale_method": ( + list(SUPPORTED_DETAIL_UPSCALE_METHODS), + { + "default": "lanczos", + "tooltip": tooltips.DETAIL_UPSCALE_METHOD, + }, + ), + "clamp_size": ( + "INT", + { + "default": 0, + "min": 0, + "max": 16384, + "tooltip": tooltips.DETAIL_CLAMP_SIZE, + }, + ), + "seed": ( + "INT", + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": tooltips.SAMPLING_SEED, + }, + ), + "steps": ( + "INT", + { + "default": 20, + "min": 1, + "max": 10000, + "tooltip": tooltips.SAMPLING_STEPS, + }, + ), + "cfg": ( + "FLOAT", + { + "default": 8.0, + "min": 0.0, + "max": 100.0, + "step": 0.1, + "tooltip": tooltips.SAMPLING_CFG, + }, + ), + "sampler_name": ( + sampling_samplers.available_samplers(), + {"tooltip": tooltips.SAMPLER_NAME}, + ), + "scheduler": ( + sampling_schedulers.available_schedulers(), + {"tooltip": tooltips.SCHEDULER}, + ), + "denoise": ( + "FLOAT", + { + "default": 0.5, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": tooltips.DENOISE_STRENGTH, + }, + ), + "feather": ( + "INT", + { + "default": 5, + "min": 0, + "max": 512, + "tooltip": tooltips.DETAIL_FEATHER, + }, + ), + "noise_mask": ( + "BOOLEAN", + { + "default": True, + "tooltip": tooltips.DETAIL_NOISE_MASK, + }, + ), + "noise_mask_feather": ( + "INT", + { + "default": 20, + "min": 0, + "max": 512, + "tooltip": tooltips.DETAIL_NOISE_MASK_FEATHER, + }, + ), + "tiled_encode": ( + "BOOLEAN", + { + "default": False, + "tooltip": tooltips.DETAIL_TILED_ENCODE, + }, + ), + "tiled_decode": ( + "BOOLEAN", + { + "default": False, + "tooltip": tooltips.DETAIL_TILED_DECODE, + }, + ), + } + } + + def detail( + self, + image: object, + segs: object, + 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, + ) -> tuple[object]: + """Run scale-factor detailing and return the detailed image.""" + + list_mode = isinstance(image, list) + images = image_inputs(image, OPERATION) + segs_group = coerce_segs_group(segs) + validate_image_segs_pairing(images, segs_group, OPERATION) + + service = self.service_class() + outputs: list[torch.Tensor] = [] + for single_image, single_segs in zip(images, segs_group, strict=True): + result = service.detail( + image=single_image, + segs=single_segs, + model=single_input(model, "model", list_mode, OPERATION), + vae=single_input(vae, "vae", list_mode, OPERATION), + positive=single_input(positive, "positive", list_mode, OPERATION), + negative=single_input(negative, "negative", list_mode, OPERATION), + scale_factor=float_input( + scale_factor, "scale_factor", list_mode, OPERATION + ), + upscale_method=str_input( + upscale_method, "upscale_method", list_mode, OPERATION + ), + clamp_size=int_input(clamp_size, "clamp_size", list_mode, OPERATION), + seed=int_input(seed, "seed", list_mode, OPERATION), + steps=int_input(steps, "steps", list_mode, OPERATION), + cfg=float_input(cfg, "cfg", list_mode, OPERATION), + sampler_name=str_input( + sampler_name, "sampler_name", list_mode, OPERATION + ), + scheduler=str_input(scheduler, "scheduler", list_mode, OPERATION), + denoise=float_input(denoise, "denoise", list_mode, OPERATION), + feather=int_input(feather, "feather", list_mode, OPERATION), + noise_mask=bool_input(noise_mask, "noise_mask", list_mode, OPERATION), + noise_mask_feather=int_input( + noise_mask_feather, "noise_mask_feather", list_mode, OPERATION + ), + tiled_encode=bool_input( + tiled_encode, "tiled_encode", list_mode, OPERATION + ), + tiled_decode=bool_input( + tiled_decode, "tiled_decode", list_mode, OPERATION + ), + ) + outputs.append(result.image) + return (torch.cat(outputs, dim=0),) diff --git a/simple_syrup/nodes/detail_segs_by_scale_factor_tiled_diffusion.py b/simple_syrup/nodes/detail_segs_by_scale_factor_tiled_diffusion.py new file mode 100644 index 0000000..7b179ae --- /dev/null +++ b/simple_syrup/nodes/detail_segs_by_scale_factor_tiled_diffusion.py @@ -0,0 +1,328 @@ +# 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 tiled diffusion SEGS scale-factor detailing.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import torch + +from ..domain.segs import coerce_segs_group +from ..domain.tiled_diffusion import TILED_DIFFUSION_MODES +from ..nodes import tooltips +from ..nodes.detailer_input_adapters import ( + bool_input, + float_input, + image_inputs, + int_input, + single_input, + str_input, + validate_image_segs_pairing, +) +from ..runtime import sampling_samplers, sampling_schedulers +from ..runtime.detail_resize import SUPPORTED_DETAIL_UPSCALE_METHODS +from ..services.detail_segs_by_scale_factor_tiled_diffusion_service import ( + DetailSEGSByScaleFactorTiledDiffusionService, +) +from .scale_factor import scale_factor_options + +OPERATION = "Detail SEGS by Scale Factor w/ Tiled Diffusion" +MAX_LATENT_TILE_SIZE = 512 + + +class DetailSEGSByScaleFactorTiledDiffusion: + """Detail SEGS crops with scale-factor sizing and tiled diffusion.""" + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + INPUT_IS_LIST = True + OUTPUT_TOOLTIPS = (tooltips.DETAIL_IMAGE_OUTPUT,) + FUNCTION = "detail" + CATEGORY = "SimpleSyrup/Detailing" + DESCRIPTION = "Details scaled SEGS crops with tiled diffusion sampling." + SEARCH_ALIASES = [ + "detailer", + "segs detailer", + "scale factor", + "multidiffusion detailer", + "mixture of diffusers detailer", + "tiled diffusion detailer", + ] + + service_class: ClassVar[type[DetailSEGSByScaleFactorTiledDiffusionService]] = ( + DetailSEGSByScaleFactorTiledDiffusionService + ) + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare ComfyUI inputs for tiled scale-factor detailing.""" + + return { + "required": { + "image": ("IMAGE", {"tooltip": tooltips.DETAIL_IMAGE}), + "segs": ("SEGS", {"tooltip": tooltips.DETAIL_SEGS}), + "model": ("MODEL", {"tooltip": tooltips.DETAIL_MODEL}), + "vae": ("VAE", {"tooltip": tooltips.DETAIL_VAE}), + "positive": ( + "CONDITIONING,CONDITIONING_BATCH", + {"tooltip": tooltips.DETAIL_POSITIVE}, + ), + "negative": ( + "CONDITIONING,CONDITIONING_BATCH", + {"tooltip": tooltips.DETAIL_NEGATIVE}, + ), + "scale_factor": ( + "FLOAT", + scale_factor_options(default=1.5), + ), + "upscale_method": ( + list(SUPPORTED_DETAIL_UPSCALE_METHODS), + { + "default": "lanczos", + "tooltip": tooltips.DETAIL_UPSCALE_METHOD, + }, + ), + "clamp_size": ( + "INT", + { + "default": 0, + "min": 0, + "max": 16384, + "tooltip": tooltips.DETAIL_CLAMP_SIZE, + }, + ), + "seed": ( + "INT", + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": tooltips.SAMPLING_SEED, + }, + ), + "steps": ( + "INT", + { + "default": 20, + "min": 1, + "max": 10000, + "tooltip": tooltips.SAMPLING_STEPS, + }, + ), + "cfg": ( + "FLOAT", + { + "default": 8.0, + "min": 0.0, + "max": 100.0, + "step": 0.1, + "tooltip": tooltips.SAMPLING_CFG, + }, + ), + "sampler_name": ( + sampling_samplers.available_samplers(), + {"tooltip": tooltips.SAMPLER_NAME}, + ), + "scheduler": ( + sampling_schedulers.available_schedulers(), + {"tooltip": tooltips.SCHEDULER}, + ), + "denoise": ( + "FLOAT", + { + "default": 0.5, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": tooltips.DENOISE_STRENGTH, + }, + ), + "feather": ( + "INT", + { + "default": 5, + "min": 0, + "max": 512, + "tooltip": tooltips.DETAIL_FEATHER, + }, + ), + "noise_mask": ( + "BOOLEAN", + { + "default": True, + "tooltip": tooltips.DETAIL_NOISE_MASK, + }, + ), + "noise_mask_feather": ( + "INT", + { + "default": 20, + "min": 0, + "max": 512, + "tooltip": tooltips.DETAIL_NOISE_MASK_FEATHER, + }, + ), + "tiled_encode": ( + "BOOLEAN", + { + "default": False, + "tooltip": tooltips.DETAIL_TILED_ENCODE, + }, + ), + "tiled_decode": ( + "BOOLEAN", + { + "default": False, + "tooltip": tooltips.DETAIL_TILED_DECODE, + }, + ), + "diffusion_mode": ( + list(TILED_DIFFUSION_MODES), + { + "default": "multidiffusion", + "tooltip": ( + "Tiled sampling blend method. MultiDiffusion is steady; " + "Mixture of Diffusers can blend tile predictions more " + "softly." + ), + }, + ), + "latent_tile_width": ( + "INT", + { + "default": 128, + "min": 16, + "max": MAX_LATENT_TILE_SIZE, + "step": 16, + "tooltip": tooltips.LATENT_TILE_WIDTH, + }, + ), + "latent_tile_height": ( + "INT", + { + "default": 128, + "min": 16, + "max": MAX_LATENT_TILE_SIZE, + "step": 16, + "tooltip": tooltips.LATENT_TILE_HEIGHT, + }, + ), + "latent_tile_overlap": ( + "INT", + { + "default": 16, + "min": 0, + "max": 256, + "step": 4, + "tooltip": tooltips.LATENT_TILE_OVERLAP, + }, + ), + "latent_tile_batch_size": ( + "INT", + { + "default": 4, + "min": 1, + "max": 8, + "step": 1, + "tooltip": tooltips.LATENT_TILE_BATCH_SIZE, + }, + ), + } + } + + def detail( + self, + image: object, + segs: object, + 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, + ) -> tuple[object]: + """Run tiled diffusion scale-factor detailing and return the image.""" + + list_mode = isinstance(image, list) + images = image_inputs(image, OPERATION) + segs_group = coerce_segs_group(segs) + validate_image_segs_pairing(images, segs_group, OPERATION) + + service = self.service_class() + outputs: list[torch.Tensor] = [] + for single_image, single_segs in zip(images, segs_group, strict=True): + result = service.detail( + image=single_image, + segs=single_segs, + model=single_input(model, "model", list_mode, OPERATION), + vae=single_input(vae, "vae", list_mode, OPERATION), + positive=single_input(positive, "positive", list_mode, OPERATION), + negative=single_input(negative, "negative", list_mode, OPERATION), + scale_factor=float_input( + scale_factor, "scale_factor", list_mode, OPERATION + ), + upscale_method=str_input( + upscale_method, "upscale_method", list_mode, OPERATION + ), + clamp_size=int_input(clamp_size, "clamp_size", list_mode, OPERATION), + seed=int_input(seed, "seed", list_mode, OPERATION), + steps=int_input(steps, "steps", list_mode, OPERATION), + cfg=float_input(cfg, "cfg", list_mode, OPERATION), + sampler_name=str_input( + sampler_name, "sampler_name", list_mode, OPERATION + ), + scheduler=str_input(scheduler, "scheduler", list_mode, OPERATION), + denoise=float_input(denoise, "denoise", list_mode, OPERATION), + feather=int_input(feather, "feather", list_mode, OPERATION), + noise_mask=bool_input(noise_mask, "noise_mask", list_mode, OPERATION), + noise_mask_feather=int_input( + noise_mask_feather, "noise_mask_feather", list_mode, OPERATION + ), + tiled_encode=bool_input( + tiled_encode, "tiled_encode", list_mode, OPERATION + ), + tiled_decode=bool_input( + tiled_decode, "tiled_decode", list_mode, OPERATION + ), + diffusion_mode=str_input( + diffusion_mode, "diffusion_mode", list_mode, OPERATION + ), + latent_tile_width=int_input( + latent_tile_width, "latent_tile_width", list_mode, OPERATION + ), + latent_tile_height=int_input( + latent_tile_height, "latent_tile_height", list_mode, OPERATION + ), + latent_tile_overlap=int_input( + latent_tile_overlap, "latent_tile_overlap", list_mode, OPERATION + ), + latent_tile_batch_size=int_input( + latent_tile_batch_size, + "latent_tile_batch_size", + list_mode, + OPERATION, + ), + ) + outputs.append(result.image) + return (torch.cat(outputs, dim=0),) diff --git a/simple_syrup/nodes/detailer_input_adapters.py b/simple_syrup/nodes/detailer_input_adapters.py new file mode 100644 index 0000000..f348c44 --- /dev/null +++ b/simple_syrup/nodes/detailer_input_adapters.py @@ -0,0 +1,155 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Input normalization helpers shared by SEGS detailer nodes.""" + +from __future__ import annotations + +from collections.abc import Sequence + +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 + + +def image_inputs(image: object, operation_name: str) -> tuple[torch.Tensor, ...]: + """Normalize direct or list-mode image inputs into one-image tensors.""" + + if isinstance(image, list): + if not image: + raise ValueError(f"{operation_name} requires an image input.") + images: list[torch.Tensor] = [] + for item in image: + images.extend( + iter_single_images(validate_image_batch(item, operation_name)) + ) + return tuple(images) + return iter_single_images(validate_image_batch(image, operation_name)) + + +def single_input( + value: object, + name: str, + list_mode: bool, + operation_name: str, +) -> object: + """Return one scalar input from a direct or Comfy list-mode value.""" + + if not list_mode: + return value + if not isinstance(value, list): + return value + if len(value) != 1: + raise ValueError( + f"{operation_name} requires one '{name}' value; received {len(value)}." + ) + return value[0] + + +def float_input( + value: object, + name: str, + list_mode: bool, + operation_name: str, +) -> float: + """Return a float scalar from a direct or list-mode input.""" + + raw_value = single_input(value, name, list_mode, operation_name) + if isinstance(raw_value, (int, float, str)): + return float(raw_value) + raise TypeError(f"{operation_name} requires '{name}' to be a float.") + + +def int_input( + value: object, + name: str, + list_mode: bool, + operation_name: str, +) -> int: + """Return an integer scalar from a direct or list-mode input.""" + + raw_value = single_input(value, name, list_mode, operation_name) + if isinstance(raw_value, (int, float, str)): + return int(raw_value) + raise TypeError(f"{operation_name} requires '{name}' to be an int.") + + +def str_input( + value: object, + name: str, + list_mode: bool, + operation_name: str, +) -> str: + """Return a string scalar from a direct or list-mode input.""" + + raw_value = single_input(value, name, list_mode, operation_name) + if isinstance(raw_value, str): + return raw_value + raise TypeError(f"{operation_name} requires '{name}' to be a string.") + + +def bool_input( + value: object, + name: str, + list_mode: bool, + operation_name: str, +) -> bool: + """Return a boolean scalar from a direct or list-mode input.""" + + raw_value = single_input(value, name, list_mode, operation_name) + if isinstance(raw_value, bool): + return raw_value + raise TypeError(f"{operation_name} requires '{name}' to be a boolean.") + + +def validate_image_segs_pairing( + images: tuple[torch.Tensor, ...], + segs_group: tuple[NativeSegs, ...], + operation_name: str, +) -> None: + """Ensure image and SEGS group cardinality is unambiguous.""" + + image_count = len(images) + segs_count = len(segs_group) + if image_count == segs_count: + return + raise ValueError( + f"{operation_name} requires one SEGS payload per image when " + f"processing an image batch; received {image_count} images and " + f"{segs_count} SEGS payloads." + ) + + +def conditioning_batch_group( + value: object, + expected_count: int, + operation_name: str, +) -> tuple[ConditioningBatch, ...]: + """Return one regional conditioning batch per SEGS payload.""" + + if expected_count == 1 and isinstance(value, ConditioningBatch): + return (value,) + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + raise TypeError( + f"{operation_name} requires one region_positive CONDITIONING_BATCH " + "per SEGS payload." + ) + if len(value) != expected_count: + raise ValueError( + f"{operation_name} requires one region_positive CONDITIONING_BATCH " + f"per SEGS payload; received {len(value)} conditioning batches for " + f"{expected_count} SEGS payloads." + ) + + batches: list[ConditioningBatch] = [] + for index, item in enumerate(value, start=1): + if not isinstance(item, ConditioningBatch): + raise TypeError( + f"{operation_name} requires region_positive item {index} to be " + "CONDITIONING_BATCH." + ) + batches.append(item) + return tuple(batches) diff --git a/simple_syrup/nodes/detect_segs_with_ultralytics.py b/simple_syrup/nodes/detect_segs_with_ultralytics.py new file mode 100644 index 0000000..ffb4be0 --- /dev/null +++ b/simple_syrup/nodes/detect_segs_with_ultralytics.py @@ -0,0 +1,213 @@ +# 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 Ultralytics SEGS detection.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, ClassVar + +import torch + +from ..domain.segs import ( + SORT_ORDER_OPTIONS, + NativeSegs, + 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 ..services.segs_detection_service import ( + SegsDetectionService, +) +from ..services.segs_output_service import ( + CombinedSegsResult, + build_combined_segs_result, +) + + +class DetectSEGSWithUltralytics: + """Detect regions using Simple Detector SEGS-style controls.""" + + RETURN_TYPES = ("SEGS", "MASK") + RETURN_NAMES = ("segs", "mask") + OUTPUT_IS_LIST = (True, False) + OUTPUT_TOOLTIPS = ( + "Detected regions as separate or combined SEGS based on combine_segs.", + "Combined detected area as a standard ComfyUI mask.", + ) + FUNCTION = "detect" + CATEGORY = "SimpleSyrup/Detection" + DESCRIPTION = ( + "Detects regions with an Ultralytics model and returns individual SEGS, " + "combined SEGS when requested, and a combined mask." + ) + SEARCH_ALIASES = ["ultralytics", "yolo", "segs", "detector"] + + service_class: ClassVar[type[SegsDetectionService]] = SegsDetectionService + combined_builder: ClassVar[Callable[[object, NativeSegs], CombinedSegsResult]] = ( + build_combined_segs_result + ) + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, Any]]: + """Declare ComfyUI inputs for detector-to-SEGS conversion.""" + + return { + "required": { + "image": ( + "IMAGE", + {"tooltip": "Image to search for detectable regions."}, + ), + "detector_model": ( + "DETECTOR_MODEL", + { + "tooltip": ( + "Ultralytics model that finds boxes or masks in the " + "input image." + ) + }, + ), + "confidence_threshold": ( + "FLOAT", + { + "default": 0.5, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": ( + "Minimum detection confidence required to keep a region." + ), + }, + ), + "size_threshold": ( + "INT", + { + "default": 10, + "min": 1, + "max": 8192, + "tooltip": ( + "Discard regions whose detected box is smaller than this " + "many pixels wide or tall." + ), + }, + ), + "bbox_dilation": ( + "INT", + { + "default": 0, + "min": -512, + "max": 512, + "step": 1, + "tooltip": ( + "Grow or shrink initial detection boxes in pixels before " + "masks are built." + ), + }, + ), + "sub_dilation": ( + "INT", + { + "default": 0, + "min": -512, + "max": 512, + "step": 1, + "tooltip": ( + "Grow or shrink the segmentation refinement mask in " + "pixels before it is applied to detected regions." + ), + }, + ), + "post_dilation": ( + "INT", + { + "default": 0, + "min": -512, + "max": 512, + "step": 1, + "tooltip": ( + "Grow or shrink each final cropped SEG mask in pixels " + "after detection and refinement." + ), + }, + ), + "crop_factor": ( + "FLOAT", + { + "default": 3.0, + "min": 0.0, + "max": 100.0, + "step": 0.1, + "tooltip": ( + "How much context to include around each detected region. " + "Use 0 for the full image; higher values make larger " + "SEG crops." + ), + }, + ), + "sort_order": ( + SORT_ORDER_OPTIONS, + { + "default": SORT_ORDER_OPTIONS[0], + "tooltip": ( + "Order the returned SEGS before output and before the " + "combined mask is built." + ), + }, + ), + "combine_segs": ( + "BOOLEAN", + { + "default": False, + "label_on": "combined", + "label_off": "separate", + "tooltip": ( + "Return one unioned SEGS region instead of separate " + "regions." + ), + }, + ), + }, + } + + def detect( + self, + image: object, + detector_model: UltralyticsDetectorModel, + confidence_threshold: float, + size_threshold: int, + bbox_dilation: int, + sub_dilation: int, + post_dilation: int, + crop_factor: float, + sort_order: str, + combine_segs: bool, + ) -> tuple[object, object]: + """Run detection and return SEGS plus the combined mask output.""" + + image_batch = validate_image_batch(image, "SEGS detector") + service = self.service_class() + + segs_outputs: list[object] = [] + mask_outputs: list[torch.Tensor] = [] + for single_image in iter_single_images(image_batch): + segs = service.detect_simple( + image=single_image, + detector_model=detector_model, + bbox_threshold=confidence_threshold, + bbox_dilation=bbox_dilation, + crop_factor=crop_factor, + drop_size=size_threshold, + sub_threshold=confidence_threshold, + sub_dilation=sub_dilation, + post_dilation=post_dilation, + ) + segs = sort_segs(segs, sort_order) + combined = type(self).combined_builder(single_image, segs) + output_segs = combined.segs if combine_segs else segs + segs_outputs.append(to_impact_compatible_segs(output_segs)) + mask_outputs.append(combined.mask) + + return segs_outputs, torch.cat(mask_outputs, dim=0) diff --git a/simple_syrup/nodes/encode_prompt_batch.py b/simple_syrup/nodes/encode_prompt_batch.py new file mode 100644 index 0000000..6f54511 --- /dev/null +++ b/simple_syrup/nodes/encode_prompt_batch.py @@ -0,0 +1,93 @@ +# 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 standard prompt batch encoding.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from ..domain.conditioning_batch import split_prompt_batch +from ..runtime.conditioning_encoding import ComfyConditioningEncoder + + +class EncodePromptBatch: + """Encode separator-delimited prompts into conditioning batches.""" + + 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.", + ) + FUNCTION = "encode" + CATEGORY = "SimpleSyrup/Conditioning" + DESCRIPTION = ( + "Encodes [SEP]-separated prompts into per-segment conditioning batches." + ) + SEARCH_ALIASES = ["conditioning batch", "prompt batch", "segs prompts"] + + encoder_class: ClassVar[type[ComfyConditioningEncoder]] = ComfyConditioningEncoder + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare prompt batch encoder inputs.""" + + return { + "required": { + "clip": ( + "CLIP", + { + "tooltip": ( + "Text encoder used to turn each prompt entry into " + "conditioning." + ) + }, + ), + "positive_prompt": ( + "STRING", + { + "default": "", + "multiline": True, + "tooltip": ( + "Positive prompts in SEGS order, separated by [SEP]." + ), + }, + ), + "negative_prompt": ( + "STRING", + { + "default": "", + "multiline": True, + "tooltip": ( + "Negative prompts in SEGS order, separated by [SEP]." + ), + }, + ), + "separator": ( + "STRING", + { + "default": "[SEP]", + "tooltip": "Text marker that separates prompt entries.", + }, + ), + } + } + + def encode( + self, + clip: Any, + positive_prompt: str, + negative_prompt: str, + separator: str, + ) -> 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), + ) diff --git a/simple_syrup/nodes/grounded_sam_model_info.py b/simple_syrup/nodes/grounded_sam_model_info.py new file mode 100644 index 0000000..de6c874 --- /dev/null +++ b/simple_syrup/nodes/grounded_sam_model_info.py @@ -0,0 +1,56 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI node declaration for grounded SAM model metadata.""" + +from __future__ import annotations + +from typing import Any + +from ..runtime.model_catalog import grounding_dino_choices, sam_choices +from ..runtime.model_metadata import GroundedSAMModelMetadata +from . import tooltips + + +class GroundedSAMModelInfo: + """Expose selected grounded SAM source and local path metadata.""" + + _metadata = GroundedSAMModelMetadata() + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("model_info",) + OUTPUT_TOOLTIPS = ( + "JSON metadata describing the selected SAM and GroundingDINO model sources.", + ) + FUNCTION = "describe" + CATEGORY = "SimpleSyrup/Masking" + DESCRIPTION = "Returns JSON metadata for selected SAM and GroundingDINO models." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare deterministic model metadata inputs.""" + + return { + "required": { + "sam_model": ( + sam_choices(), + { + "default": "sam_hq_vit_b (379MB)", + "tooltip": tooltips.SAM_MODEL_INPUT, + }, + ), + "grounding_dino_model": ( + grounding_dino_choices(), + { + "default": "GroundingDINO_SwinT_OGC (694MB)", + "tooltip": tooltips.GROUNDING_DINO_MODEL_INPUT, + }, + ), + } + } + + def describe(self, sam_model: str, grounding_dino_model: str) -> tuple[str]: + """Return JSON metadata for selected model entries.""" + + return (self._metadata.describe_selection(sam_model, grounding_dino_model),) diff --git a/simple_syrup/nodes/grounding_dino_model_loader.py b/simple_syrup/nodes/grounding_dino_model_loader.py new file mode 100644 index 0000000..1c7615f --- /dev/null +++ b/simple_syrup/nodes/grounding_dino_model_loader.py @@ -0,0 +1,76 @@ +# 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 loading GroundingDINO models.""" + +from __future__ import annotations + +from typing import Any + +from ..runtime.grounding_dino_loader import ( + TEXT_ENCODER_AUTO, + TEXT_ENCODER_CHOICES, + GroundingDINOLoaderService, +) +from ..runtime.model_choices import ModelChoiceService, default_choice +from ..runtime.model_downloads import ComfyProgressReporter +from . import tooltips + + +class GroundingDINOModelLoader: + """Expose GroundingDINO loading with explicit text encoder selection.""" + + _service = GroundingDINOLoaderService() + _choices = ModelChoiceService() + + RETURN_TYPES = ("GROUNDING_DINO_MODEL",) + RETURN_NAMES = ("grounding_dino_model",) + OUTPUT_TOOLTIPS = (tooltips.GROUNDING_DINO_MODEL_OUTPUT,) + FUNCTION = "load_model" + CATEGORY = "SimpleSyrup/Masking" + DESCRIPTION = "Loads GroundingDINO and its explicit BERT text encoder." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare deterministic GroundingDINO loader inputs.""" + + choices = cls._choices.grounding_dino_choices() + return { + "required": { + "grounding_dino_model": ( + choices, + { + "default": default_choice( + choices, + "GroundingDINO_SwinT_OGC (694MB)", + ), + "tooltip": tooltips.GROUNDING_DINO_MODEL_INPUT, + }, + ), + "text_encoder": ( + list(TEXT_ENCODER_CHOICES), + { + "default": TEXT_ENCODER_AUTO, + "tooltip": tooltips.GROUNDING_DINO_TEXT_ENCODER_INPUT, + }, + ), + } + } + + def load_model( + self, + grounding_dino_model: str, + text_encoder: str, + ) -> tuple[object]: + """Load and return a GroundingDINO-compatible model object.""" + + self._choices.reject_sentinel(grounding_dino_model) + return ( + self._service.load_model( + grounding_dino_model=grounding_dino_model, + text_encoder=text_encoder, + auto_download=True, + progress=ComfyProgressReporter(), + ), + ) diff --git a/simple_syrup/nodes/image_resize_to_target.py b/simple_syrup/nodes/image_resize_to_target.py new file mode 100644 index 0000000..7311909 --- /dev/null +++ b/simple_syrup/nodes/image_resize_to_target.py @@ -0,0 +1,218 @@ +# 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 target image resizing.""" + +from __future__ import annotations + +from typing import Any + +import torch + +from ..image.resize_service import ResizeImageToTargetService + + +class ResizeImageToTarget: + """Expose target image resizing controls to ComfyUI.""" + + _service = ResizeImageToTargetService() + + RETURN_TYPES = ("IMAGE", "INT", "INT", "MASK") + RETURN_NAMES = ("image", "width", "height", "mask") + OUTPUT_TOOLTIPS = ( + "Resized image batch after aspect-ratio handling and divisibility rounding.", + "Final image width in pixels.", + "Final image height in pixels.", + "Resized mask aligned to the output image size.", + ) + FUNCTION = "resize" + CATEGORY = "SimpleSyrup/Image" + DESCRIPTION = ( + "Resize image batches to a target size with selectable aspect handling, " + "sampler, and CPU/GPU processor." + ) + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare ComfyUI inputs for target image resizing.""" + + return { + "required": { + "image": ( + "IMAGE", + {"tooltip": "Image batch to resize to the target dimensions."}, + ), + "width": ( + "INT", + { + "default": 1024, + "min": 1, + "max": 16384, + "step": 1, + "tooltip": ( + "Target width in pixels before divisibility rounding." + ), + }, + ), + "height": ( + "INT", + { + "default": 1024, + "min": 1, + "max": 16384, + "step": 1, + "tooltip": ( + "Target height in pixels before divisibility rounding." + ), + }, + ), + "resize_mode": ( + ["Stretch", "Keep AR", "Crop (Cover + Crop)", "Pad (Fit + Pad)"], + { + "default": "Keep AR", + "tooltip": ( + "How aspect ratio is handled. Stretch fills exactly, crop " + "trims overflow, and pad fills empty space." + ), + }, + ), + "sampling": ( + ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"], + { + "default": "lanczos", + "tooltip": ( + "Resize filter. Sharper filters preserve detail but can " + "show more ringing." + ), + }, + ), + "processor": ( + ["cpu", "gpu"], + { + "default": "gpu", + "tooltip": ( + "Processor used for resizing. GPU is usually faster; CPU " + "can reduce GPU memory pressure." + ), + }, + ), + "divisible_by": ( + "INT", + { + "default": 1, + "min": 1, + "max": 4096, + "step": 1, + "tooltip": ( + "Round final dimensions to a multiple of this value for " + "model or latent-size compatibility." + ), + }, + ), + "crop_position": ( + [ + "center", + "top-left", + "top", + "top-right", + "left", + "right", + "bottom-left", + "bottom", + "bottom-right", + ], + { + "default": "center", + "tooltip": ( + "Anchor used when crop mode trims overflow from the " + "resized image." + ), + }, + ), + "pad_color": ( + "STRING", + { + "default": "0, 0, 0", + "multiline": False, + "tooltip": "RGB color used to fill empty space in pad mode.", + }, + ), + "max_batch_size": ( + "INT", + { + "default": 0, + "min": 0, + "max": 4096, + "step": 1, + "tooltip": ( + "Maximum images resized at once. Lower values reduce " + "memory use; 0 processes the full batch together." + ), + }, + ), + "sinc_window": ( + "INT", + { + "default": 3, + "min": 1, + "max": 8, + "step": 1, + "tooltip": ( + "Lanczos window size. Higher values can look sharper but " + "may add ringing." + ), + }, + ), + "precision": ( + ["fp32", "fp16", "bf16"], + { + "default": "fp32", + "tooltip": ( + "Math precision for resizing. Lower precision can save " + "memory but may slightly change results." + ), + }, + ), + }, + "optional": { + "mask": ( + "MASK", + {"tooltip": "Optional mask to resize with the image batch."}, + ), + }, + } + + def resize( + self, + image: torch.Tensor, + width: int, + height: int, + resize_mode: str, + sampling: str, + processor: str, + divisible_by: int, + crop_position: str, + pad_color: str, + max_batch_size: int, + sinc_window: int, + precision: str, + mask: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, int, int, torch.Tensor]: + """Resize an image batch through the application service.""" + + return self._service.resize( + image=image, + width=width, + height=height, + resize_mode=resize_mode, + sampling=sampling, + processor=processor, + divisible_by=divisible_by, + crop_position=crop_position, + pad_color=pad_color, + max_batch_size=max_batch_size, + sinc_window=sinc_window, + precision=precision, + mask=mask, + ) diff --git a/simple_syrup/nodes/ksampler_extras.py b/simple_syrup/nodes/ksampler_extras.py new file mode 100644 index 0000000..8ca1699 --- /dev/null +++ b/simple_syrup/nodes/ksampler_extras.py @@ -0,0 +1,181 @@ +# 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 KSampler Extras.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + +from ..runtime import sampling_samplers, sampling_schedulers +from . import tooltips + +Latent = dict[str, Any] + + +class KSamplerExtras: + """Expose KSampler-style sampling with AYS and GITS scheduler options.""" + + RETURN_TYPES = ("LATENT",) + OUTPUT_TOOLTIPS = (tooltips.DENOISED_LATENT_OUTPUT,) + FUNCTION = "sample" + CATEGORY = "SimpleSyrup/Sampling" + DESCRIPTION = ( + "Denoises latents with extended sampler and scheduler options for " + "compatible workflows." + ) + SEARCH_ALIASES = ["ksampler", "sampler", "ays", "gits", "lcm"] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare ComfyUI inputs for extra scheduler sampling.""" + + return { + "required": { + "model": ("MODEL", {"tooltip": tooltips.SAMPLING_MODEL}), + "seed": ( + "INT", + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": tooltips.SAMPLING_SEED, + }, + ), + "steps": ( + "INT", + { + "default": 20, + "min": 1, + "max": 10000, + "tooltip": tooltips.SAMPLING_STEPS, + }, + ), + "cfg": ( + "FLOAT", + { + "default": 8.0, + "min": 0.0, + "max": 100.0, + "step": 0.1, + "round": 0.01, + "tooltip": tooltips.SAMPLING_CFG, + }, + ), + "sampler_name": ( + sampling_samplers.available_samplers(), + {"tooltip": tooltips.SAMPLER_NAME}, + ), + "scheduler": ( + sampling_schedulers.available_schedulers(), + {"tooltip": tooltips.SCHEDULER}, + ), + "positive": ( + "CONDITIONING", + {"tooltip": tooltips.POSITIVE_CONDITIONING}, + ), + "negative": ( + "CONDITIONING", + {"tooltip": tooltips.NEGATIVE_CONDITIONING}, + ), + "latent_image": ("LATENT", {"tooltip": tooltips.LATENT_IMAGE}), + "denoise": ( + "FLOAT", + { + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": tooltips.DENOISE_STRENGTH, + }, + ), + } + } + + def sample( + self, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float = 1.0, + ) -> tuple[Latent]: + """Sample a latent with ComfyUI samplers and extra scheduler sigmas.""" + + sampler = sampling_samplers.resolve_sampler(sampler_name) + sigmas = sampling_schedulers.calculate_sigmas( + model=model, + scheduler_name=scheduler, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + ).to(model.load_device) + + latent_samples = latent_image["samples"] + comfy_sample = _comfy_sample() + comfy_utils = _comfy_utils() + latent_preview = _latent_preview() + + latent_samples = comfy_sample.fix_empty_latent_channels( + model, + latent_samples, + latent_image.get("downscale_ratio_spacial", None), + ) + + batch_inds = ( + latent_image["batch_index"] if "batch_index" in latent_image else None + ) + noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds) + noise_mask = latent_image.get("noise_mask", None) + + callback = latent_preview.prepare_callback(model, steps) + disable_pbar = not comfy_utils.PROGRESS_BAR_ENABLED + samples = comfy_sample.sample_custom( + model, + noise, + cfg, + sampler, + sigmas, + positive, + negative, + latent_samples, + noise_mask=noise_mask, + callback=callback, + disable_pbar=disable_pbar, + seed=seed, + ) + + output = latent_image.copy() + output.pop("downscale_ratio_spacial", None) + output["samples"] = samples + return (output,) + + +def _comfy_sample() -> Any: + """Import ComfyUI sample helpers lazily.""" + + import comfy.sample + + return comfy.sample + + +def _comfy_utils() -> Any: + """Import ComfyUI utility state lazily.""" + + import comfy.utils + + return comfy.utils + + +def _latent_preview() -> Any: + """Import ComfyUI preview helpers lazily.""" + + return import_module("latent_preview") diff --git a/simple_syrup/nodes/ksampler_tiled_diffusion.py b/simple_syrup/nodes/ksampler_tiled_diffusion.py new file mode 100644 index 0000000..0ba0cd7 --- /dev/null +++ b/simple_syrup/nodes/ksampler_tiled_diffusion.py @@ -0,0 +1,197 @@ +# 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 selectable tiled diffusion sampling.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from ..domain.tiled_diffusion import TILED_DIFFUSION_MODES +from ..runtime import sampling_samplers, sampling_schedulers +from ..services.tiled_diffusion_sampling_service import TiledDiffusionSamplingService +from . import tooltips + +Latent = dict[str, Any] +MAX_LATENT_TILE_SIZE = 512 + + +class KSamplerTiledDiffusion: + """Sample latents with selectable tiled diffusion denoising.""" + + RETURN_TYPES = ("LATENT",) + OUTPUT_TOOLTIPS = (tooltips.DENOISED_LATENT_OUTPUT,) + FUNCTION = "sample" + CATEGORY = "SimpleSyrup/Sampling" + DESCRIPTION = "Denoises latents with selectable tiled diffusion sampling." + SEARCH_ALIASES = [ + "ksampler", + "sampler", + "tiled diffusion", + "multidiffusion", + "multi diffusion", + "mixture of diffusers", + ] + + service_class: ClassVar[type[TiledDiffusionSamplingService]] = ( + TiledDiffusionSamplingService + ) + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare ComfyUI inputs for selectable tiled diffusion sampling.""" + + return { + "required": { + "model": ("MODEL", {"tooltip": tooltips.SAMPLING_MODEL}), + "seed": ( + "INT", + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": tooltips.SAMPLING_SEED, + }, + ), + "steps": ( + "INT", + { + "default": 20, + "min": 1, + "max": 10000, + "tooltip": tooltips.SAMPLING_STEPS, + }, + ), + "cfg": ( + "FLOAT", + { + "default": 8.0, + "min": 0.0, + "max": 100.0, + "step": 0.1, + "round": 0.01, + "tooltip": tooltips.SAMPLING_CFG, + }, + ), + "sampler_name": ( + sampling_samplers.available_samplers(), + {"tooltip": tooltips.SAMPLER_NAME}, + ), + "scheduler": ( + sampling_schedulers.available_schedulers(), + {"tooltip": tooltips.SCHEDULER}, + ), + "positive": ( + "CONDITIONING", + {"tooltip": tooltips.POSITIVE_CONDITIONING}, + ), + "negative": ( + "CONDITIONING", + {"tooltip": tooltips.NEGATIVE_CONDITIONING}, + ), + "latent_image": ("LATENT", {"tooltip": tooltips.LATENT_IMAGE}), + "denoise": ( + "FLOAT", + { + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": tooltips.DENOISE_STRENGTH, + }, + ), + "diffusion_mode": ( + list(TILED_DIFFUSION_MODES), + { + "default": "multidiffusion", + "tooltip": ( + "Tiled sampling blend method. MultiDiffusion is steady; " + "Mixture of Diffusers can blend tile predictions more " + "softly." + ), + }, + ), + "latent_tile_width": ( + "INT", + { + "default": 128, + "min": 16, + "max": MAX_LATENT_TILE_SIZE, + "step": 16, + "tooltip": tooltips.LATENT_TILE_WIDTH, + }, + ), + "latent_tile_height": ( + "INT", + { + "default": 128, + "min": 16, + "max": MAX_LATENT_TILE_SIZE, + "step": 16, + "tooltip": tooltips.LATENT_TILE_HEIGHT, + }, + ), + "latent_tile_overlap": ( + "INT", + { + "default": 16, + "min": 0, + "max": 256, + "step": 4, + "tooltip": tooltips.LATENT_TILE_OVERLAP, + }, + ), + "latent_tile_batch_size": ( + "INT", + { + "default": 4, + "min": 1, + "max": 8, + "step": 1, + "tooltip": tooltips.LATENT_TILE_BATCH_SIZE, + }, + ), + } + } + + def sample( + self, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float = 1.0, + diffusion_mode: str = "multidiffusion", + latent_tile_width: int = 128, + latent_tile_height: int = 128, + latent_tile_overlap: int = 16, + latent_tile_batch_size: int = 4, + ) -> tuple[Latent]: + """Sample a latent with the selected tiled diffusion method.""" + + output = self.service_class().sample( + diffusion_mode=diffusion_mode, + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=None, + ) + return (output,) diff --git a/simple_syrup/nodes/latent_diagnostics.py b/simple_syrup/nodes/latent_diagnostics.py new file mode 100644 index 0000000..b4ff39d --- /dev/null +++ b/simple_syrup/nodes/latent_diagnostics.py @@ -0,0 +1,64 @@ +# 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 latent metadata diagnostics.""" + +from __future__ import annotations + +from typing import Any, TypedDict + +from ..services.latent_diagnostics_service import LatentDiagnosticsService + +Latent = dict[str, Any] + + +class LatentDiagnosticsResult(TypedDict): + """ComfyUI execution result with UI text and typed output data.""" + + ui: dict[str, list[str]] + result: tuple[Latent, str] + + +class LatentDiagnostics: + """Expose safe latent tensor metadata for debugging model compatibility.""" + + _service = LatentDiagnosticsService() + + RETURN_TYPES = ("LATENT", "STRING") + RETURN_NAMES = ("latent", "report") + OUTPUT_TOOLTIPS = ( + "Input latent passed through unchanged for continued workflow use.", + "Text report describing latent shape, dtype, device, and tiling fit.", + ) + FUNCTION = "analyze" + CATEGORY = "SimpleSyrup/Utilities" + DESCRIPTION = ( + "Reports latent tensor shape, dtype, device, and tiling compatibility." + ) + OUTPUT_NODE = True + SEARCH_ALIASES = ["latent", "diagnostics", "inspect latent", "debug latent"] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare the latent diagnostic input contract.""" + + return { + "required": { + "latent": ( + "LATENT", + { + "tooltip": ( + "Latent to inspect. The node reports metadata and passes " + "it through unchanged." + ) + }, + ) + } + } + + def analyze(self, latent: Latent) -> LatentDiagnosticsResult: + """Return the input latent and a metadata report for ComfyUI display.""" + + report = self._service.describe(latent) + return {"ui": {"text": [report]}, "result": (latent, report)} diff --git a/simple_syrup/nodes/layerstyle_sam_models_adapter.py b/simple_syrup/nodes/layerstyle_sam_models_adapter.py new file mode 100644 index 0000000..9334fe8 --- /dev/null +++ b/simple_syrup/nodes/layerstyle_sam_models_adapter.py @@ -0,0 +1,50 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Adapter node for LayerStyle combined SAM model bundles.""" + +from __future__ import annotations + + +class LayerStyleSAMModelsAdapter: + """Split a LayerStyle SAM/DINO bundle into conventional model sockets.""" + + RETURN_TYPES = ("SAM_MODEL", "DINO_MODEL") + RETURN_NAMES = ("sam_model", "dino_model") + OUTPUT_TOOLTIPS = ( + "SAM model split from the LayerStyle bundle for SimpleSyrup mask nodes.", + "DINO model split from the LayerStyle bundle for SimpleSyrup detection nodes.", + ) + FUNCTION = "adapt" + CATEGORY = "SimpleSyrup/Masking" + DESCRIPTION = "Splits LayerStyle LS_SAM_MODELS into SAM_MODEL and DINO_MODEL." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[object, ...]]]: + """Declare the LayerStyle bundle input.""" + + return { + "required": { + "sam_models": ( + "LS_SAM_MODELS", + { + "tooltip": ( + "LayerStyle SAM/DINO bundle to split into separate " + "SimpleSyrup-compatible model sockets." + ) + }, + ) + } + } + + def adapt(self, sam_models: object) -> tuple[object, object]: + """Return SAM and DINO objects from a LayerStyle-style bundle.""" + + if not isinstance(sam_models, dict): + raise TypeError( + "LS_SAM_MODELS must be a mapping with SAM_MODEL and DINO_MODEL." + ) + if "SAM_MODEL" not in sam_models or "DINO_MODEL" not in sam_models: + raise ValueError("LS_SAM_MODELS must contain SAM_MODEL and DINO_MODEL.") + return sam_models["SAM_MODEL"], sam_models["DINO_MODEL"] diff --git a/simple_syrup/nodes/load_ultralytics_model.py b/simple_syrup/nodes/load_ultralytics_model.py new file mode 100644 index 0000000..368b1cb --- /dev/null +++ b/simple_syrup/nodes/load_ultralytics_model.py @@ -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 loading Ultralytics models.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from ..runtime.ultralytics_loader import UltralyticsLoaderService + + +class LoadUltralyticsModel: + """Load one Ultralytics model and expose native and compatibility outputs.""" + + RETURN_TYPES = ("DETECTOR_MODEL", "BBOX_DETECTOR", "SEGM_DETECTOR") + RETURN_NAMES = ("detector_model", "bbox_detector", "segm_detector") + OUTPUT_TOOLTIPS = ( + "Detector model for SimpleSyrup SEGS detection nodes.", + "Bounding-box detector output for nodes that expect a bbox detector.", + "Segmentation detector output for nodes that expect a mask-capable detector.", + ) + FUNCTION = "load" + CATEGORY = "SimpleSyrup/Detection" + DESCRIPTION = "Loads an Ultralytics model for detection and SEGS workflows." + SEARCH_ALIASES = ["ultralytics", "yolo", "detector"] + + service_class: ClassVar[type[UltralyticsLoaderService]] = UltralyticsLoaderService + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare ComfyUI inputs for Ultralytics model loading.""" + + choices = cls.service_class().model_choices() + return { + "required": { + "model_name": ( + choices, + { + "default": choices[0], + "tooltip": ( + "Ultralytics model file in the ComfyUI models folder." + ), + }, + ) + } + } + + def load(self, model_name: str) -> tuple[object, object, object]: + """Load the selected detector and paired compatibility facades.""" + + loaded = self.service_class().load(model_name) + return loaded.detector_model, loaded.bbox_detector, loaded.segm_detector diff --git a/simple_syrup/nodes/prompt_encode_style.py b/simple_syrup/nodes/prompt_encode_style.py new file mode 100644 index 0000000..cdf403e --- /dev/null +++ b/simple_syrup/nodes/prompt_encode_style.py @@ -0,0 +1,45 @@ +# 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 Prompt Control encode-style tags.""" + +from __future__ import annotations + +from typing import Any + +from ..domain.prompt_style import ENCODE_STYLE_VALUES, format_style_tag + + +class PromptEncodeStyle: + """Build Prompt Control STYLE tags from encode-style selections.""" + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("style_tag",) + OUTPUT_TOOLTIPS = ("Prompt Control STYLE tag text for prompt encoding workflows.",) + FUNCTION = "build" + CATEGORY = "SimpleSyrup/Prompt" + DESCRIPTION = "Builds a Prompt Control STYLE tag from an encode-style selection." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare Prompt Control encode-style selection inputs.""" + + return { + "required": { + "encode_style": ( + list(ENCODE_STYLE_VALUES), + { + "default": "A1111", + "tooltip": ( + "Prompt Control encoding style to write into the STYLE tag." + ), + }, + ), + } + } + + def build(self, encode_style: str) -> tuple[str]: + """Return a Prompt Control STYLE tag.""" + + return (format_style_tag(encode_style),) diff --git a/simple_syrup/nodes/prompt_encode_style_and_normalization.py b/simple_syrup/nodes/prompt_encode_style_and_normalization.py new file mode 100644 index 0000000..0664006 --- /dev/null +++ b/simple_syrup/nodes/prompt_encode_style_and_normalization.py @@ -0,0 +1,64 @@ +# 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 Prompt Control encode-style normalization tags.""" + +from __future__ import annotations + +from typing import Any + +from ..domain.prompt_style import ( + ENCODE_STYLE_VALUES, + NORMALIZATION_VALUES, + format_style_tag_with_normalization, +) + + +class PromptEncodeStyleAndNormalization: + """Build STYLE tags from encode-style and normalization selections.""" + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("style_tag",) + OUTPUT_TOOLTIPS = ( + "Prompt Control STYLE tag text with the selected normalization behavior.", + ) + FUNCTION = "build" + CATEGORY = "SimpleSyrup/Prompt" + DESCRIPTION = ( + "Builds a Prompt Control STYLE tag from encode-style and normalization " + "selections." + ) + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare Prompt Control encode-style and normalization selection inputs.""" + + return { + "required": { + "encode_style": ( + list(ENCODE_STYLE_VALUES), + { + "default": "A1111", + "tooltip": ( + "Prompt Control encoding style to write into the STYLE tag." + ), + }, + ), + "normalization": ( + list(NORMALIZATION_VALUES), + { + "default": "none", + "tooltip": ( + "Prompt weight normalization mode written into the " + "STYLE tag." + ), + }, + ), + } + } + + def build(self, encode_style: str, normalization: str) -> tuple[str]: + """Return a Prompt Control STYLE tag.""" + + return (format_style_tag_with_normalization(encode_style, normalization),) diff --git a/simple_syrup/nodes/prompt_segs_with_sam.py b/simple_syrup/nodes/prompt_segs_with_sam.py new file mode 100644 index 0000000..bdf3f40 --- /dev/null +++ b/simple_syrup/nodes/prompt_segs_with_sam.py @@ -0,0 +1,341 @@ +# 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 prompt-based SAM SEGS detection.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, ClassVar + +import torch + +from ..domain.segs import ( + SORT_ORDER_OPTIONS, + NativeSegs, + to_impact_compatible_segs, +) +from ..masking.mask_ops import DETAIL_METHODS +from ..masking.prompt_segs_with_sam_service import PromptSEGSWithSAMService +from ..masking.segs_mask_ops import iter_single_images, validate_image_batch +from ..services.segs_output_service import ( + CombinedSegsResult, + build_combined_segs_result, +) + + +class PromptSEGSWithSAM: + """Expose prompt-based SAM region detection as SEGS.""" + + _service = PromptSEGSWithSAMService() + + RETURN_TYPES = ("SEGS", "MASK") + RETURN_NAMES = ("segs", "mask") + OUTPUT_IS_LIST = (True, False) + OUTPUT_TOOLTIPS = ( + "Prompted regions as separate or combined SEGS based on combine_segs.", + "Combined prompted area as a standard ComfyUI mask.", + ) + FUNCTION = "prompt" + CATEGORY = "SimpleSyrup/Detection" + DESCRIPTION = ( + "Finds prompt-matched regions with GroundingDINO, segments them with SAM, " + "and returns SEGS plus a combined mask." + ) + SEARCH_ALIASES = ["sam", "groundingdino", "prompt", "segs"] + + combined_builder: ClassVar[Callable[[object, NativeSegs], CombinedSegsResult]] = ( + build_combined_segs_result + ) + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, Any]]: + """Declare deterministic ComfyUI inputs without model IO.""" + + return { + "required": { + "image": ( + "IMAGE", + {"tooltip": "Image to search with the text prompt."}, + ), + "sam_model": ( + "SAM_MODEL", + { + "tooltip": ( + "SAM model used to create masks for the prompted boxes." + ) + }, + ), + "grounding_dino_model": ( + "GROUNDING_DINO_MODEL,DINO_MODEL", + { + "tooltip": ( + "GroundingDINO model used to find boxes that match the " + "prompt." + ) + }, + ), + "positive_prompt": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": "Text describing the regions to detect.", + }, + ), + "negative_prompt": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": ( + "Text describing areas to subtract from the detected " + "regions." + ), + }, + ), + "confidence_threshold": ( + "FLOAT", + { + "default": 0.3, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": ( + "Minimum GroundingDINO confidence required to create a " + "SAM prompt box." + ), + }, + ), + "size_threshold": ( + "INT", + { + "default": 10, + "min": 1, + "max": 8192, + "tooltip": ( + "Discard final regions whose mask bounds are smaller " + "than this many pixels wide or tall." + ), + }, + ), + "bbox_dilation": ( + "INT", + { + "default": 0, + "min": -512, + "max": 512, + "step": 1, + "tooltip": ( + "Grow or shrink prompt boxes in pixels before sending " + "them to SAM." + ), + }, + ), + "mask_dilation": ( + "INT", + { + "default": 0, + "min": -512, + "max": 512, + "step": 1, + "tooltip": ( + "Grow or shrink each final region mask in pixels after " + "prompt subtraction." + ), + }, + ), + "detail_method": ( + list(DETAIL_METHODS), + { + "default": "GuidedFilter", + "tooltip": "Method used to refine mask edges.", + }, + ), + "detail_erode": ( + "INT", + { + "default": 6, + "min": 0, + "max": 255, + "step": 1, + "tooltip": ( + "Pixels trimmed inside the mask edge during refinement. " + "Higher values pull the refined edge inward." + ), + }, + ), + "detail_dilate": ( + "INT", + { + "default": 6, + "min": 0, + "max": 255, + "step": 1, + "tooltip": ( + "Pixels added outside the mask edge during refinement. " + "Higher values give refinement more outside context." + ), + }, + ), + "black_point": ( + "FLOAT", + { + "default": 0.15, + "min": 0.0, + "max": 0.98, + "step": 0.01, + "tooltip": "Mask values at or below this become black.", + }, + ), + "white_point": ( + "FLOAT", + { + "default": 0.99, + "min": 0.02, + "max": 1.0, + "step": 0.01, + "tooltip": "Mask values at or above this become white.", + }, + ), + "refine_mask": ( + "BOOLEAN", + { + "default": True, + "label_on": "enabled", + "label_off": "disabled", + "tooltip": "Enable edge refinement for the final masks.", + }, + ), + "mask_refinement_max_size": ( + "INT", + { + "default": 2048, + "min": 1, + "max": 16384, + "step": 64, + "tooltip": ( + "Maximum working size for mask refinement. Larger " + "images are refined at a bounded size for performance." + ), + }, + ), + "execution_device": ( + ["auto", "cpu"], + { + "default": "auto", + "tooltip": ( + "Device for model-backed refinement. Auto uses normal " + "ComfyUI placement; CPU avoids GPU memory pressure." + ), + }, + ), + "crop_factor": ( + "FLOAT", + { + "default": 3.0, + "min": 1.0, + "max": 100.0, + "step": 0.1, + "tooltip": ( + "How much context to include around each prompted " + "region. Higher values make larger SEG crops." + ), + }, + ), + "sort_order": ( + SORT_ORDER_OPTIONS, + { + "default": SORT_ORDER_OPTIONS[0], + "tooltip": ( + "Order the returned SEGS before output and before the " + "combined mask is built." + ), + }, + ), + "combine_segs": ( + "BOOLEAN", + { + "default": False, + "label_on": "combined", + "label_off": "separate", + "tooltip": ( + "Return one unioned SEGS region instead of separate " + "regions." + ), + }, + ), + }, + "optional": { + "vitmatte_model": ( + "VITMATTE_MODEL", + { + "tooltip": ( + "Optional ViTMatte model used when detail_method is " + "VITMatte." + ) + }, + ), + }, + } + + def prompt( + self, + image: object, + sam_model: object, + grounding_dino_model: object, + positive_prompt: str, + negative_prompt: str, + confidence_threshold: float, + size_threshold: int, + bbox_dilation: int, + mask_dilation: int, + detail_method: str, + detail_erode: int, + detail_dilate: int, + black_point: float, + white_point: float, + refine_mask: bool, + mask_refinement_max_size: int, + execution_device: str, + crop_factor: float, + sort_order: str, + combine_segs: bool, + vitmatte_model: object | None = None, + ) -> tuple[object, object]: + """Prompt regions and return SEGS plus the combined mask output.""" + + image_batch = validate_image_batch(image, "Prompt SEGS w/ SAM") + + segs_outputs: list[object] = [] + mask_outputs: list[torch.Tensor] = [] + for single_image in iter_single_images(image_batch): + segs = self._service.prompt( + image=single_image, + sam_model=sam_model, + grounding_dino_model=grounding_dino_model, + vitmatte_model=vitmatte_model, + positive_prompt=positive_prompt, + negative_prompt=negative_prompt, + confidence_threshold=confidence_threshold, + size_threshold=size_threshold, + bbox_dilation=bbox_dilation, + mask_dilation=mask_dilation, + detail_method=detail_method, + detail_erode=detail_erode, + detail_dilate=detail_dilate, + black_point=black_point, + white_point=white_point, + refine_mask=refine_mask, + mask_refinement_max_size=mask_refinement_max_size, + execution_device=execution_device, + crop_factor=crop_factor, + sort_order=sort_order, + ) + combined = type(self).combined_builder(single_image, segs) + output_segs = combined.segs if combine_segs else segs + segs_outputs.append(to_impact_compatible_segs(output_segs)) + mask_outputs.append(combined.mask) + + return segs_outputs, torch.cat(mask_outputs, dim=0) diff --git a/simple_syrup/nodes/provenance_latent.py b/simple_syrup/nodes/provenance_latent.py new file mode 100644 index 0000000..188e92f --- /dev/null +++ b/simple_syrup/nodes/provenance_latent.py @@ -0,0 +1,278 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI nodes that reuse latent provenance behind decoded images.""" + +from __future__ import annotations + +from collections.abc import Mapping +from importlib import import_module +from typing import Any, TypedDict, cast + +from ..domain.graph_provenance import BrokenProvenance, GraphLink, VaeDecodeProvenance +from ..runtime.comfy_graph_provenance import ( + ProvenanceTrace, + links_match, + trace_vae_decode_provenance, +) + +LATENT_PROVENANCE_ERROR = ( + "Unable to find an unmodified VAE Decode source for this image. Connect an " + "image that is directly decoded from a latent, or place this node immediately " + "after a transparent cube boundary whose image output comes from VAE Decode." +) + + +class ExpansionResult(TypedDict): + """ComfyUI dynamic expansion result returned by provenance-aware nodes.""" + + expand: dict[str, dict[str, Any]] + result: tuple[list[object], ...] + + +class SimpleVAEEncode: + """Encode images while reusing source latents when provenance proves safety.""" + + RETURN_TYPES = ("LATENT",) + RETURN_NAMES = ("latent",) + OUTPUT_TOOLTIPS = ( + "Latent recovered from the decoded image source or produced by normal " + "VAE encoding.", + ) + FUNCTION = "encode" + CATEGORY = "SimpleSyrup/Latent" + DESCRIPTION = ( + "Encodes an image to latent space, reusing the source latent when the image " + "is proven to be an unmodified VAE decode." + ) + SEARCH_ALIASES = [ + "vae encode", + "encode image", + "recover latent", + "reuse decoded latent", + ] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, Any]]: + """Declare the provenance-aware VAE encode input contract.""" + + return { + "required": { + "image": ( + "IMAGE", + { + "rawLink": True, + "tooltip": ( + "Image to encode. If it comes directly from VAE Decode " + "through transparent pass-through nodes, the original " + "latent is reused; edited or loaded images are encoded " + "normally." + ), + }, + ), + "vae": ( + "VAE", + { + "rawLink": True, + "tooltip": ( + "VAE used for normal image encoding. Latent reuse is " + "used only when this VAE matches the VAE that decoded " + "the source image." + ), + }, + ), + }, + "hidden": {"prompt": "PROMPT"}, + } + + def encode( + self, + image: object, + vae: object, + prompt: Mapping[str, Any] | None = None, + ) -> ExpansionResult: + """Return the source latent when safe, otherwise expand to `VAEEncode`.""" + + provenance = _trace_prompt_provenance(prompt, image) + if ( + isinstance(provenance, VaeDecodeProvenance) + and provenance.vae_link is not None + and links_match(vae, provenance.vae_link) + ): + return _link_result(provenance.samples_link) + + return _vae_encode_expansion(image, vae) + + +class UpscaleLatentFromImage: + """Upscale the latent behind an unmodified decoded image.""" + + UPSCALE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"] + + RETURN_TYPES = ("LATENT",) + RETURN_NAMES = ("latent",) + OUTPUT_TOOLTIPS = ( + "Upscaled latent produced from the source latent behind the decoded image.", + ) + FUNCTION = "upscale" + CATEGORY = "SimpleSyrup/Latent" + DESCRIPTION = "Upscales the latent that produced an unmodified decoded image." + SEARCH_ALIASES = [ + "upscale latent", + "latent upscale from image", + "decoded image latent upscale", + ] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, Any]]: + """Declare the latent-upscale-from-image input contract.""" + + return { + "required": { + "image": ( + "IMAGE", + { + "rawLink": True, + "tooltip": ( + "Connect an image that comes directly from VAE Decode, " + "with only transparent pass-through nodes between them. " + "Pixel edits, image upscalers, crops, detailers, " + "loaders, and previews break latent provenance." + ), + }, + ), + "upscale_method": ( + cls.UPSCALE_METHODS, + { + "tooltip": ( + "Interpolation method passed to Comfy's Upscale Latent " + "By behavior." + ) + }, + ), + "scale_factor": ( + "FLOAT", + { + "default": 1.5, + "min": 0.01, + "max": 8.0, + "step": 0.01, + "tooltip": ( + "Multiplier for the latent width and height passed to " + "Comfy's Upscale Latent By behavior." + ), + }, + ), + }, + "hidden": {"prompt": "PROMPT"}, + } + + def upscale( + self, + image: object, + upscale_method: str, + scale_factor: float, + prompt: Mapping[str, Any] | None = None, + ) -> ExpansionResult: + """Expand to Comfy's `LatentUpscaleBy` when provenance is valid.""" + + provenance = _trace_prompt_provenance(prompt, image) + if isinstance(provenance, BrokenProvenance): + raise ValueError(LATENT_PROVENANCE_ERROR) + return _latent_upscale_by_expansion( + provenance.samples_link, + upscale_method, + scale_factor, + ) + + +def _trace_prompt_provenance( + prompt: Mapping[str, Any] | None, + image: object, +) -> ProvenanceTrace: + """Trace an image link with the active ComfyUI node registry.""" + + if prompt is None: + return BrokenProvenance("prompt metadata is unavailable") + return trace_vae_decode_provenance(prompt, image, _node_registry()) + + +def _link_result(link: GraphLink) -> ExpansionResult: + """Return an existing graph link through a dynamic expansion result.""" + + return {"expand": {}, "result": (_to_comfy_link(link),)} + + +def _vae_encode_expansion(image: object, vae: object) -> ExpansionResult: + """Build a dynamic `VAEEncode` fallback graph.""" + + builder = _graph_builder() + encoded = builder.node( + "VAEEncode", + pixels=_graph_value(image), + vae=_graph_value(vae), + ) + return {"expand": builder.finalize(), "result": (encoded.out(0),)} + + +def _latent_upscale_by_expansion( + samples_link: GraphLink, + upscale_method: str, + scale_factor: float, +) -> ExpansionResult: + """Build a dynamic `LatentUpscaleBy` graph around the source latent.""" + + builder = _graph_builder() + upscaled = builder.node( + "LatentUpscaleBy", + samples=_to_comfy_link(samples_link), + upscale_method=upscale_method, + scale_by=scale_factor, + ) + return {"expand": builder.finalize(), "result": (upscaled.out(0),)} + + +def _graph_value(value: object) -> object: + """Normalize graph-link values while preserving regular fallback values.""" + + if isinstance(value, tuple): + link = _tuple_link(value) + if link is not None: + return _to_comfy_link(link) + return value + + +def _tuple_link(value: tuple[object, ...]) -> GraphLink | None: + """Return a typed graph link from a tuple value when possible.""" + + if len(value) != 2: + return None + node_id, output_slot = value + if not isinstance(node_id, str) or not isinstance(output_slot, int): + return None + return (node_id, output_slot) + + +def _to_comfy_link(link: GraphLink) -> list[object]: + """Convert an internal graph link tuple to Comfy's serialized list shape.""" + + return [link[0], link[1]] + + +def _node_registry() -> Mapping[str, type[object]]: + """Return ComfyUI's active node registry without importing it at module load.""" + + nodes_module = import_module("nodes") + return cast( + Mapping[str, type[object]], + nodes_module.NODE_CLASS_MAPPINGS, + ) + + +def _graph_builder() -> Any: + """Return Comfy's dynamic graph builder without import-time Comfy coupling.""" + + graph_utils = import_module("comfy_execution.graph_utils") + graph_builder = cast(Any, graph_utils.GraphBuilder) + return graph_builder() diff --git a/simple_syrup/nodes/sam_model_loader.py b/simple_syrup/nodes/sam_model_loader.py new file mode 100644 index 0000000..af38fbc --- /dev/null +++ b/simple_syrup/nodes/sam_model_loader.py @@ -0,0 +1,60 @@ +# 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 loading SAM models.""" + +from __future__ import annotations + +from typing import Any + +from ..runtime.model_choices import ModelChoiceService, default_choice +from ..runtime.model_downloads import ComfyProgressReporter +from ..runtime.sam_loader import SAMLoaderService +from . import tooltips + + +class SAMModelLoader: + """Expose SAM loading as a conventional ComfyUI model loader.""" + + _service = SAMLoaderService() + _choices = ModelChoiceService() + + RETURN_TYPES = ("SAM_MODEL",) + RETURN_NAMES = ("sam_model",) + OUTPUT_TOOLTIPS = (tooltips.SAM_MODEL_OUTPUT,) + FUNCTION = "load_model" + CATEGORY = "SimpleSyrup/Masking" + DESCRIPTION = "Loads a SAM model for Prompt SEGS w/ SAM and compatible nodes." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare deterministic SAM loader inputs.""" + + choices = cls._choices.sam_choices() + return { + "required": { + "sam_model": ( + choices, + { + "default": default_choice(choices, "sam_vit_b (375MB)"), + "tooltip": tooltips.SAM_MODEL_INPUT, + }, + ), + } + } + + def load_model( + self, + sam_model: str, + ) -> tuple[object]: + """Load and return a SAM-compatible model object.""" + + self._choices.reject_sentinel(sam_model) + return ( + self._service.load_model( + sam_model=sam_model, + auto_download=True, + progress=ComfyProgressReporter(), + ), + ) diff --git a/simple_syrup/nodes/scale_factor.py b/simple_syrup/nodes/scale_factor.py new file mode 100644 index 0000000..19e84c5 --- /dev/null +++ b/simple_syrup/nodes/scale_factor.py @@ -0,0 +1,63 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI node declaration for bounded scale-factor values.""" + +from __future__ import annotations + +from typing import Any + +from . import tooltips + +SCALE_FACTOR_DEFAULT = 1.5 +SCALE_FACTOR_MIN = 1.0 +SCALE_FACTOR_MAX = 5.0 +SCALE_FACTOR_STEP = 0.1 + + +def scale_factor_options( + default: float = SCALE_FACTOR_DEFAULT, + tooltip: str = tooltips.DETAIL_SCALE_FACTOR, +) -> dict[str, object]: + """Return ComfyUI widget options for scale-factor controls.""" + + return { + "default": default, + "min": SCALE_FACTOR_MIN, + "max": SCALE_FACTOR_MAX, + "step": SCALE_FACTOR_STEP, + "tooltip": tooltip, + } + + +class ScaleFactor: + """Expose a bounded float value for scale-factor inputs.""" + + RETURN_TYPES = ("FLOAT",) + RETURN_NAMES = ("scale_factor",) + OUTPUT_TOOLTIPS = (tooltips.SCALE_FACTOR_OUTPUT,) + FUNCTION = "get_value" + CATEGORY = "SimpleSyrup/Primitives" + DESCRIPTION = "Provides a bounded multiplier for scaling." + SEARCH_ALIASES = ["scale", "scale factor", "float", "primitive"] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare the bounded scale-factor value input.""" + + return { + "required": { + "value": ( + "FLOAT", + scale_factor_options(tooltip=tooltips.SCALE_FACTOR_VALUE), + ), + } + } + + def get_value(self, value: object) -> tuple[float]: + """Return the provided scale-factor value.""" + + if isinstance(value, (int, float, str)): + return (float(value),) + raise TypeError("Scale Factor requires 'value' to be a float.") diff --git a/simple_syrup/nodes/seed.py b/simple_syrup/nodes/seed.py new file mode 100644 index 0000000..d7a9bc1 --- /dev/null +++ b/simple_syrup/nodes/seed.py @@ -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 + +"""ComfyUI node declaration for reusable seed values.""" + +from __future__ import annotations + + +class Seed: + """Expose ComfyUI's native seed widget as a reusable integer output.""" + + RETURN_TYPES = ("INT",) + RETURN_NAMES = ("seed",) + OUTPUT_TOOLTIPS = ("Same seed value for wiring into multiple nodes.",) + FUNCTION = "execute" + CATEGORY = "SimpleSyrup/Utilities" + DESCRIPTION = "Provides a reusable seed value with ComfyUI seed controls." + SEARCH_ALIASES = ["seed", "random seed"] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[object, ...]]]: + """Declare the native ComfyUI seed input contract.""" + + return { + "required": { + "seed": ( + "INT", + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": ( + "Seed value to reuse across nodes. Matching seed and " + "settings make random choices repeatable." + ), + }, + ) + } + } + + def execute(self, seed: int) -> tuple[int]: + """Return the selected seed unchanged.""" + + return (seed,) diff --git a/simple_syrup/nodes/simple_load_anima.py b/simple_syrup/nodes/simple_load_anima.py new file mode 100644 index 0000000..8fefa3f --- /dev/null +++ b/simple_syrup/nodes/simple_load_anima.py @@ -0,0 +1,133 @@ +# 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 simple Anima model loading.""" + +from __future__ import annotations + +import importlib +from types import ModuleType +from typing import Any + +from ..runtime.anima_loader import ( + AUTO_CHOICE, + CLIP_DEVICES, + DIFFUSION_WEIGHT_DTYPES, + AnimaLoaderService, +) +from ..runtime.model_downloads import ComfyProgressReporter +from ..runtime.vae_loader import vae_choices +from . import tooltips + + +class SimpleLoadAnima: + """Expose Anima diffusion, text encoder, and VAE loading as one node.""" + + _service = AnimaLoaderService() + + RETURN_TYPES = ("MODEL", "CLIP", "VAE") + RETURN_NAMES = ("model", "clip", "vae") + OUTPUT_TOOLTIPS = ( + tooltips.MODEL_OUTPUT, + tooltips.CLIP_OUTPUT, + tooltips.VAE_OUTPUT, + ) + FUNCTION = "load_models" + CATEGORY = "SimpleSyrup/Loaders" + DESCRIPTION = "Loads Anima model components with auto-resolved Qwen assets." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare deterministic Simple Load Anima inputs.""" + + folder_paths = _folder_paths() + return { + "required": { + "diffusion_model": ( + folder_paths.get_filename_list("diffusion_models"), + { + "tooltip": ( + "Anima diffusion model file used as the main image " + "generation model." + ) + }, + ), + "diffusion_weight_dtype": ( + list(DIFFUSION_WEIGHT_DTYPES), + { + "default": "default", + "advanced": True, + "tooltip": ( + "Weight precision for Anima. Lower precision can reduce " + "memory use but may slightly change results." + ), + }, + ), + "text_encoder": ( + _choices_with_auto(folder_paths.get_filename_list("text_encoders")), + { + "default": AUTO_CHOICE, + "tooltip": ( + "Qwen text encoder used for Anima prompt understanding. " + "Auto selects the expected model." + ), + }, + ), + "text_encoder_device": ( + list(CLIP_DEVICES), + { + "default": "default", + "advanced": True, + "tooltip": ( + "Device for the text encoder. Auto follows the normal " + "ComfyUI placement." + ), + }, + ), + "vae": ( + _choices_with_auto(vae_choices(folder_paths)), + { + "default": AUTO_CHOICE, + "tooltip": ( + "VAE used to decode Anima latents. Auto selects the " + "expected Qwen image VAE." + ), + }, + ), + } + } + + def load_models( + self, + diffusion_model: str, + diffusion_weight_dtype: str, + text_encoder: str, + text_encoder_device: str, + vae: str, + ) -> tuple[object, object, object]: + """Load and return Anima's MODEL, CLIP, and VAE objects.""" + + return self._service.load_models( + diffusion_model=diffusion_model, + diffusion_weight_dtype=diffusion_weight_dtype, + text_encoder=text_encoder, + text_encoder_device=text_encoder_device, + vae=vae, + progress=ComfyProgressReporter(), + ) + + +def _choices_with_auto(choices: list[str]) -> list[str]: + """Return choices with the automatic selection first and deduplicated.""" + + return [AUTO_CHOICE, *(choice for choice in choices if choice != AUTO_CHOICE)] + + +def _folder_paths() -> ModuleType: + """Import ComfyUI folder paths lazily.""" + + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + return module diff --git a/simple_syrup/nodes/simple_load_checkpoint.py b/simple_syrup/nodes/simple_load_checkpoint.py new file mode 100644 index 0000000..63119c4 --- /dev/null +++ b/simple_syrup/nodes/simple_load_checkpoint.py @@ -0,0 +1,96 @@ +# 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 checkpoint loading with optional VAE override.""" + +from __future__ import annotations + +import importlib +from types import ModuleType +from typing import Any + +from ..runtime.checkpoint_loader import ( + CLIP_SKIP_DEFAULT, + USE_CHECKPOINT_VAE_CHOICE, + CheckpointLoaderService, +) +from ..runtime.vae_loader import vae_choices +from . import tooltips + + +class SimpleLoadCheckpoint: + """Expose checkpoint loading with an optional external VAE override.""" + + _service = CheckpointLoaderService() + + RETURN_TYPES = ("MODEL", "CLIP", "VAE") + RETURN_NAMES = ("model", "clip", "vae") + OUTPUT_TOOLTIPS = ( + tooltips.MODEL_OUTPUT, + tooltips.CLIP_OUTPUT, + tooltips.VAE_OUTPUT, + ) + FUNCTION = "load_checkpoint" + CATEGORY = "SimpleSyrup/Loaders" + DESCRIPTION = "Loads a checkpoint and optionally replaces its VAE." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare deterministic Simple Load Checkpoint inputs.""" + + folder_paths = _folder_paths() + return { + "required": { + "ckpt_name": ( + folder_paths.get_filename_list("checkpoints"), + {"tooltip": tooltips.CHECKPOINT_MODEL_INPUT}, + ), + "vae_name": ( + _choices_with_checkpoint_vae(vae_choices(folder_paths)), + { + "default": USE_CHECKPOINT_VAE_CHOICE, + "tooltip": tooltips.CHECKPOINT_VAE_INPUT, + }, + ), + "clip_skip": ( + "BOOLEAN", + { + "default": CLIP_SKIP_DEFAULT, + "tooltip": tooltips.CLIP_SKIP_INPUT, + }, + ), + } + } + + def load_checkpoint( + self, + ckpt_name: str, + vae_name: str, + clip_skip: bool, + ) -> tuple[object, object, object]: + """Load checkpoint MODEL and CLIP with the selected VAE.""" + + return self._service.load_checkpoint( + ckpt_name=ckpt_name, + vae_name=vae_name, + clip_skip=clip_skip, + ) + + +def _choices_with_checkpoint_vae(choices: list[str]) -> list[str]: + """Return choices with the checkpoint VAE selection first and deduplicated.""" + + return [ + USE_CHECKPOINT_VAE_CHOICE, + *(choice for choice in choices if choice != USE_CHECKPOINT_VAE_CHOICE), + ] + + +def _folder_paths() -> ModuleType: + """Import ComfyUI folder paths lazily.""" + + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + return module diff --git a/simple_syrup/nodes/tile_and_tag_segs.py b/simple_syrup/nodes/tile_and_tag_segs.py new file mode 100644 index 0000000..69b2ff5 --- /dev/null +++ b/simple_syrup/nodes/tile_and_tag_segs.py @@ -0,0 +1,255 @@ +# 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 Tile & Tag SEGS.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, ClassVar + +from ..domain.tile_segs import IRREGULAR_MASK_MODES, TileSEGSControls +from ..nodes import tooltips +from ..runtime.wd14_tagger import WD14TagFormattingControls +from ..services.tile_and_tag_segs_service import TileAndTagSEGSService + +DEFAULT_EXCLUDE_TAGS = "1girl, solo, long_hair, short_hair, silhouette" + + +class TileAndTagSEGS: + """Create tile SEGS and WD14 conditioning for SEGS detailing.""" + + RETURN_TYPES = ("SEGS", "CONDITIONING_BATCH") + RETURN_NAMES = ("segs", "positive") + OUTPUT_TOOLTIPS = ( + tooltips.TILE_SEGS_OUTPUT, + tooltips.TILE_POSITIVE_OUTPUT, + ) + FUNCTION = "tile_and_tag" + CATEGORY = "SimpleSyrup/Detailing" + DESCRIPTION = ( + "Creates tile SEGS, tags each tile with a connected WD14 tagger, and " + "returns aligned conditioning for SEGS detailing." + ) + SEARCH_ALIASES = ["tile", "tag", "wd14", "segs", "detail"] + + service_class: ClassVar[Callable[[], TileAndTagSEGSService]] = TileAndTagSEGSService + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare ComfyUI inputs for tile creation and WD14 tagging.""" + + return { + "required": { + "image": ("IMAGE", {"tooltip": tooltips.TILE_IMAGE}), + "clip": ("CLIP", {"tooltip": tooltips.TILE_CLIP}), + "wd14_tagger": ( + "WD14_TAGGER", + {"tooltip": tooltips.TILE_WD14_TAGGER}, + ), + "universal_positive": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": tooltips.TILE_UNIVERSAL_POSITIVE, + }, + ), + "bbox_size": ( + "INT", + { + "default": 872, + "min": 64, + "max": 4096, + "step": 8, + "tooltip": tooltips.TILE_BBOX_SIZE, + }, + ), + "crop_factor": ( + "FLOAT", + { + "default": 1.1, + "min": 1.0, + "max": 10.0, + "step": 0.01, + "tooltip": tooltips.TILE_CROP_FACTOR, + }, + ), + "min_overlap": ( + "INT", + { + "default": 16, + "min": 0, + "max": 512, + "step": 1, + "tooltip": tooltips.TILE_MIN_OVERLAP, + }, + ), + "filter_segs_dilation": ( + "INT", + { + "default": 20, + "min": -255, + "max": 255, + "step": 1, + "tooltip": tooltips.TILE_FILTER_SEGS_DILATION, + }, + ), + "mask_irregularity": ( + "FLOAT", + { + "default": 0, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": tooltips.TILE_MASK_IRREGULARITY, + }, + ), + "irregular_mask_mode": ( + IRREGULAR_MASK_MODES, + { + "default": "Reuse fast", + "tooltip": tooltips.TILE_IRREGULAR_MASK_MODE, + }, + ), + "threshold": ( + "FLOAT", + { + "default": 0.35, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "tooltip": tooltips.TILE_THRESHOLD, + }, + ), + "character_threshold": ( + "FLOAT", + { + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "tooltip": tooltips.TILE_CHARACTER_THRESHOLD, + }, + ), + "replace_underscore": ( + "BOOLEAN", + { + "default": True, + "tooltip": tooltips.TILE_REPLACE_UNDERSCORE, + }, + ), + "trailing_comma": ( + "BOOLEAN", + { + "default": False, + "tooltip": tooltips.TILE_TRAILING_COMMA, + }, + ), + "exclude_tags": ( + "STRING", + { + "default": DEFAULT_EXCLUDE_TAGS, + "multiline": False, + "tooltip": tooltips.TILE_EXCLUDE_TAGS, + }, + ), + }, + } + + def tile_and_tag( + self, + image: object, + clip: Any, + wd14_tagger: object, + universal_positive: object, + bbox_size: object, + crop_factor: object, + min_overlap: object, + filter_segs_dilation: object, + mask_irregularity: object, + irregular_mask_mode: object, + threshold: object, + character_threshold: object, + replace_underscore: object, + trailing_comma: object, + exclude_tags: object, + ) -> tuple[object, object]: + """Create ordered tile SEGS and WD14 conditioning.""" + + tile_controls = TileSEGSControls( + bbox_size=_int_input(bbox_size, "bbox_size"), + crop_factor=_float_input(crop_factor, "crop_factor"), + min_overlap=_int_input(min_overlap, "min_overlap"), + filter_segs_dilation=_int_input( + filter_segs_dilation, + "filter_segs_dilation", + ), + mask_irregularity=_float_input(mask_irregularity, "mask_irregularity"), + irregular_mask_mode=_str_input( + irregular_mask_mode, + "irregular_mask_mode", + ), + ) + tag_controls = WD14TagFormattingControls( + threshold=_float_input(threshold, "threshold"), + character_threshold=_float_input( + character_threshold, + "character_threshold", + ), + replace_underscore=_bool_input( + replace_underscore, + "replace_underscore", + ), + trailing_comma=_bool_input(trailing_comma, "trailing_comma"), + exclude_tags=_str_input(exclude_tags, "exclude_tags"), + ) + result = ( + type(self) + .service_class() + .tile_and_tag( + image=image, + clip=clip, + wd14_tagger=wd14_tagger, + tile_controls=tile_controls, + tag_controls=tag_controls, + universal_positive=_str_input( + universal_positive, + "universal_positive", + ), + ) + ) + return result.segs, result.positive + + +def _int_input(value: object, name: str) -> int: + """Return an integer node input.""" + + if isinstance(value, (int, float, str)): + return int(value) + raise TypeError(f"Tile & Tag SEGS requires '{name}' to be an int.") + + +def _float_input(value: object, name: str) -> float: + """Return a float node input.""" + + if isinstance(value, (int, float, str)): + return float(value) + raise TypeError(f"Tile & Tag SEGS requires '{name}' to be a float.") + + +def _str_input(value: object, name: str) -> str: + """Return a string node input.""" + + if isinstance(value, str): + return value + raise TypeError(f"Tile & Tag SEGS requires '{name}' to be a string.") + + +def _bool_input(value: object, name: str) -> bool: + """Return a boolean node input.""" + + if isinstance(value, bool): + return value + raise TypeError(f"Tile & Tag SEGS requires '{name}' to be a boolean.") diff --git a/simple_syrup/nodes/tooltips.py b/simple_syrup/nodes/tooltips.py new file mode 100644 index 0000000..a7b6454 --- /dev/null +++ b/simple_syrup/nodes/tooltips.py @@ -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 + +"""Shared tooltip text for ComfyUI node declarations.""" + +from __future__ import annotations + +CHECKPOINT_MODEL_INPUT = ( + "Checkpoint file to load. This supplies the base MODEL, CLIP, and checkpoint " + "VAE for the workflow." +) +CHECKPOINT_VAE_INPUT = ( + "VAE to output with the checkpoint. Use the checkpoint VAE to keep the model's " + "own decoder, or choose another VAE to replace it." +) +CLIP_SKIP_INPUT = ( + "Use ComfyUI's clip-skip behavior for prompt encoding. Enable it for models " + "that expect the next-to-last CLIP layer." +) + +MODEL_OUTPUT = "Loaded diffusion model for downstream MODEL inputs." +CLIP_OUTPUT = "Loaded text encoder for downstream CLIP inputs." +VAE_OUTPUT = "Loaded VAE used to encode images to latents and decode latents to images." + +SAM_MODEL_OUTPUT = "Loaded SAM model for prompt-based mask and SEGS creation." +GROUNDING_DINO_MODEL_OUTPUT = ( + "Loaded GroundingDINO model for finding prompt-matched boxes in images." +) +VITMATTE_MODEL_OUTPUT = "Loaded ViTMatte model for refining mask edges." +WD14_TAGGER_OUTPUT = "Loaded WD14 tagger for generating prompt tags from image crops." + +SAM_MODEL_INPUT = "SAM model choice used to create masks from detected boxes." +GROUNDING_DINO_MODEL_INPUT = ( + "GroundingDINO model choice used to find boxes that match a text prompt." +) +GROUNDING_DINO_TEXT_ENCODER_INPUT = ( + "BERT text encoder paired with GroundingDINO for prompt matching." +) +VITMATTE_MODEL_INPUT = "ViTMatte model choice used for mask edge refinement." +WD14_MODEL_INPUT = "WD14 tagger model choice used to generate tags from image crops." + +SAMPLING_MODEL = "Diffusion model used to denoise the input latent." +SAMPLING_SEED = ( + "Seed used to create sampling noise. Reusing it with matching settings makes " + "results repeatable." +) +SAMPLING_STEPS = ( + "Number of denoising steps. More steps can add refinement but take longer." +) +SAMPLING_CFG = ( + "Prompt guidance strength. Higher values follow the positive prompt more " + "strongly but can look overcooked." +) +SAMPLER_NAME = "Sampling algorithm. It affects the image's look, speed, and stability." +SCHEDULER = ( + "Noise schedule used during sampling. It changes how quickly structure and " + "detail form." +) +POSITIVE_CONDITIONING = "Positive conditioning that guides what the sampler should add." +NEGATIVE_CONDITIONING = ( + "Negative conditioning that guides what the sampler should avoid." +) +LATENT_IMAGE = "Latent input whose samples will be denoised." +DENOISE_STRENGTH = ( + "Sampling strength. Lower values preserve the input more; higher values allow " + "larger changes." +) +DENOISED_LATENT_OUTPUT = "Denoised latent for VAE decode or more latent processing." + +LATENT_TILE_WIDTH = ( + "Width of each latent tile. Larger tiles see more context but use more memory." +) +LATENT_TILE_HEIGHT = ( + "Height of each latent tile. Larger tiles see more context but use more memory." +) +LATENT_TILE_OVERLAP = ( + "Overlap between latent tiles. Larger overlaps reduce seams but increase " + "sampling work." +) +LATENT_TILE_BATCH_SIZE = ( + "Number of latent tiles sampled together. Higher values can be faster but use " + "more memory." +) + +DETAIL_IMAGE = ( + "Source image containing the regions to improve. Detailed crops are blended " + "back into this image." +) +DETAIL_SEGS = "SEGS regions that choose which parts of the image are detailed." +DETAIL_MODEL = "Diffusion model used to resample each detailed crop." +DETAIL_VAE = "VAE used to encode crops to latents and decode the edited crops." +DETAIL_POSITIVE = ( + "Positive conditioning for detailing. A conditioning batch is matched to SEGS " + "order." +) +DETAIL_NEGATIVE = ( + "Negative conditioning for detailing. A conditioning batch is matched to SEGS " + "order." +) +DETAIL_SCALE_FACTOR = ( + "Crop enlargement multiplier. Larger values give the sampler more detail room " + "but use more memory." +) +SCALE_FACTOR_VALUE = ( + "Scaling multiplier. 1.0 keeps the target at its current size; larger values " + "scale it up, with a maximum of 5.0x." +) +DETAIL_UPSCALE_METHOD = ( + "Resize method for scaled crops. Sharper methods preserve detail but can show " + "more ringing." +) +DETAIL_CLAMP_SIZE = ( + "Maximum crop size in pixels after scaling. Use 0 to leave crop size unclamped." +) +DETAIL_FEATHER = ( + "Mask edge softness in pixels. Higher values blend edits more gently into the " + "image." +) +DETAIL_NOISE_MASK = ( + "Limit sampling noise to the selected mask area so unchanged pixels stay more " + "stable." +) +DETAIL_NOISE_MASK_FEATHER = ( + "Noise mask edge softness in pixels. Higher values make the sampled area fade " + "out more gradually." +) +DETAIL_TILED_ENCODE = ( + "Encode large crops in tiles. This lowers memory use but is usually slower." +) +DETAIL_TILED_DECODE = ( + "Decode large crops in tiles. This lowers memory use but is usually slower." +) +DETAIL_IMAGE_OUTPUT = "Image with the detailed regions blended back into place." +SCALE_FACTOR_OUTPUT = "Multiplier used to scale a connected target." + +REGIONAL_GLOBAL_NEGATIVE = ( + "Negative conditioning applied across the full regional pass." +) +REGIONAL_GLOBAL_POSITIVE = ( + "Positive conditioning that gives full-image context to the regional pass." +) +REGIONAL_POSITIVE_BATCH = ( + "Per-region positive conditioning matched to the incoming SEGS order." +) + +TILE_IMAGE = "Image to split into tile SEGS for tagging or downstream workflows." +TILE_CLIP = "CLIP model used to encode each generated tile prompt." +TILE_WD14_TAGGER = "WD14 tagger that reads each tile crop and suggests prompt tags." +TILE_UNIVERSAL_POSITIVE = ( + "Positive prompt text added before every generated tile tag prompt." +) +TILE_BBOX_SIZE = "Target tile box size in pixels." +TILE_CROP_FACTOR = ( + "Tile crop expansion. Larger values include more surrounding context for tags." +) +TILE_MIN_OVERLAP = ( + "Minimum pixel overlap between tile regions. Higher values reduce gaps but " + "repeat more image area." +) +TILE_FILTER_SEGS_DILATION = ( + "Grow or shrink tile masks before filtering. Positive values expand masks; " + "negative values contract them." +) +TILE_MASK_IRREGULARITY = ( + "Organic variation added to tile masks. Higher values make masks less rectangular." +) +TILE_IRREGULAR_MASK_MODE = ( + "How irregular masks are generated. Reuse is steadier; random varies each " + "tile; quality modes do more work." +) +TILE_THRESHOLD = ( + "Minimum WD14 confidence for general tags. Higher values keep fewer, more " + "certain tags." +) +TILE_CHARACTER_THRESHOLD = ( + "Minimum WD14 confidence for character tags. Higher values keep fewer, more " + "certain character tags." +) +TILE_REPLACE_UNDERSCORE = ( + "Replace underscores with spaces so generated tags read more naturally." +) +TILE_TRAILING_COMMA = ( + "Add a comma after generated tag text for easier prompt composition." +) +TILE_EXCLUDE_TAGS = "Comma-separated tags removed from generated tile prompts." +TILE_SEGS_OUTPUT = "Generated tile SEGS in the same order as the conditioning batch." +TILE_POSITIVE_OUTPUT = ( + "Positive conditioning from WD14 tile tags, matched to SEGS order." +) diff --git a/simple_syrup/nodes/vitmatte_model_loader.py b/simple_syrup/nodes/vitmatte_model_loader.py new file mode 100644 index 0000000..ee2aec2 --- /dev/null +++ b/simple_syrup/nodes/vitmatte_model_loader.py @@ -0,0 +1,63 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI node declaration for loading ViTMatte models.""" + +from __future__ import annotations + +from typing import Any + +from ..runtime.model_choices import ModelChoiceService, default_choice +from ..runtime.model_downloads import ComfyProgressReporter +from ..runtime.vitmatte_loader import ViTMatteLoaderService +from . import tooltips + + +class ViTMatteModelLoader: + """Expose ViTMatte loading as a conventional model loader.""" + + _service = ViTMatteLoaderService() + _choices = ModelChoiceService() + + RETURN_TYPES = ("VITMATTE_MODEL",) + RETURN_NAMES = ("vitmatte_model",) + OUTPUT_TOOLTIPS = (tooltips.VITMATTE_MODEL_OUTPUT,) + FUNCTION = "load_model" + CATEGORY = "SimpleSyrup/Masking" + DESCRIPTION = "Loads a ViTMatte model for Prompt SEGS w/ SAM edge refinement." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare deterministic ViTMatte loader inputs.""" + + choices = cls._choices.vitmatte_choices() + return { + "required": { + "vitmatte_model": ( + choices, + { + "default": default_choice( + choices, + "vitmatte-small-composition-1k", + ), + "tooltip": tooltips.VITMATTE_MODEL_INPUT, + }, + ), + } + } + + def load_model( + self, + vitmatte_model: str, + ) -> tuple[object]: + """Load and return a ViTMatte-compatible model object.""" + + self._choices.reject_sentinel(vitmatte_model) + return ( + self._service.load_model( + vitmatte_model=vitmatte_model, + auto_download=True, + progress=ComfyProgressReporter(), + ), + ) diff --git a/simple_syrup/nodes/wd14_tagger_loader.py b/simple_syrup/nodes/wd14_tagger_loader.py new file mode 100644 index 0000000..e2b18e6 --- /dev/null +++ b/simple_syrup/nodes/wd14_tagger_loader.py @@ -0,0 +1,61 @@ +# 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 loading WD14 taggers.""" + +from __future__ import annotations + +from typing import Any + +from ..runtime.model_catalog import DEFAULT_WD14_TAGGER_MODEL +from ..runtime.model_choices import ModelChoiceService, default_choice +from ..runtime.model_downloads import ComfyProgressReporter +from ..runtime.wd14_tagger_loader import WD14TaggerLoaderService +from . import tooltips + + +class WD14TaggerLoader: + """Expose WD14 tagger loading as a conventional ComfyUI model loader.""" + + _service = WD14TaggerLoaderService() + _choices = ModelChoiceService() + + RETURN_TYPES = ("WD14_TAGGER",) + RETURN_NAMES = ("wd14_tagger",) + OUTPUT_TOOLTIPS = (tooltips.WD14_TAGGER_OUTPUT,) + FUNCTION = "load_model" + CATEGORY = "SimpleSyrup/Tagging" + DESCRIPTION = "Loads a WD14 tagger for compatible SimpleSyrup tagging workflows." + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare deterministic WD14 tagger loader inputs.""" + + choices = cls._choices.wd14_tagger_choices() + return { + "required": { + "wd14_model": ( + choices, + { + "default": default_choice( + choices, + DEFAULT_WD14_TAGGER_MODEL, + ), + "tooltip": tooltips.WD14_MODEL_INPUT, + }, + ), + } + } + + def load_model(self, wd14_model: str) -> tuple[object]: + """Load and return a WD14 tagger model object.""" + + self._choices.reject_sentinel(wd14_model) + return ( + self._service.load_model( + wd14_model=wd14_model, + auto_download=True, + progress=ComfyProgressReporter(), + ), + ) diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py new file mode 100644 index 0000000..2733f56 --- /dev/null +++ b/simple_syrup/nodes_v3/__init__.py @@ -0,0 +1,41 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node registration for SimpleSyrup.""" + +from __future__ import annotations + +from ..runtime.prompt_control_availability import prompt_control_is_available + + +def get_nodes() -> list[type[object]]: + """Return v3 nodes that can be advertised in this environment.""" + + from .scale_factor import ScaleFactorV3 + from .simple_load_checkpoint import SimpleLoadCheckpointV3 + from .tile_and_tag_segs import TileAndTagSEGSV3 + from .wd14_tagger_loader import WD14TaggerLoaderV3 + + if not prompt_control_is_available(): + return [ + WD14TaggerLoaderV3, + TileAndTagSEGSV3, + SimpleLoadCheckpointV3, + ScaleFactorV3, + ] + + from .encode_prompt_batch_with_prompt_control import ( + EncodePromptBatchWithPromptControl, + ) + + return [ + WD14TaggerLoaderV3, + TileAndTagSEGSV3, + SimpleLoadCheckpointV3, + ScaleFactorV3, + EncodePromptBatchWithPromptControl, + ] + + +__all__ = ["get_nodes"] diff --git a/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py b/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py new file mode 100644 index 0000000..cc6a68e --- /dev/null +++ b/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py @@ -0,0 +1,113 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node for Prompt Control prompt batch encoding.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..runtime.prompt_control_batch_graph import PromptControlBatchGraphBuilder + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Return v1-compatible input metadata.""" + + raise NotImplementedError + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io +ConditioningBatchIO: Any = ( + None if TYPE_CHECKING else _comfy_io.Custom("CONDITIONING_BATCH") +) + + +class EncodePromptBatchWithPromptControl(_ComfyNodeBase): + """Encode separator-delimited prompts with Prompt Control scheduling.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the Prompt Control batch encoder schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.EncodePromptBatchWithPromptControl", + display_name="Encode Prompt Batch w/ Prompt Control", + enable_expand=True, + category="SimpleSyrup/Conditioning", + description=( + "Encodes [SEP]-separated prompts into per-segment Prompt Control " + "conditioning batches." + ), + inputs=[ + _comfy_io.Clip.Input( + "clip", + raw_link=True, + tooltip=( + "Prompt Control-ready CLIP connection used to encode each " + "prompt entry." + ), + ), + _comfy_io.String.Input( + "positive_prompt", + multiline=True, + default="", + tooltip=( + "Positive Prompt Control prompts in SEGS order, separated " + "by the separator text." + ), + ), + _comfy_io.String.Input( + "negative_prompt", + multiline=True, + default="", + tooltip=( + "Negative Prompt Control prompts in SEGS order, separated " + "by the separator text." + ), + ), + _comfy_io.String.Input( + "separator", + default="[SEP]", + tooltip="Text marker that splits prompts into per-SEGS entries.", + ), + ], + outputs=[ + ConditioningBatchIO.Output( + "positive", + tooltip="Positive conditioning entries selected by SEGS order.", + ), + ConditioningBatchIO.Output( + "negative", + tooltip="Negative conditioning entries selected by SEGS order.", + ), + ], + ) + + @classmethod + def execute( + cls, + clip: Any, + positive_prompt: str, + negative_prompt: str, + separator: str, + ) -> Any: + """Build lazy Prompt Control graph expansion for prompt batches.""" + + return PromptControlBatchGraphBuilder().build( + clip=clip, + positive_prompt=positive_prompt, + negative_prompt=negative_prompt, + separator=separator, + ) diff --git a/simple_syrup/nodes_v3/scale_factor.py b/simple_syrup/nodes_v3/scale_factor.py new file mode 100644 index 0000000..97aecfd --- /dev/null +++ b/simple_syrup/nodes_v3/scale_factor.py @@ -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 + +"""Comfy v3 node wrapper for Scale Factor.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes import tooltips +from ..nodes.scale_factor import ( + SCALE_FACTOR_DEFAULT, + SCALE_FACTOR_MAX, + SCALE_FACTOR_MIN, + SCALE_FACTOR_STEP, + ScaleFactor, +) + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class ScaleFactorV3(_ComfyNodeBase): + """Expose Scale Factor through Comfy's v3 extension API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the Scale Factor v3 schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.ScaleFactor", + display_name="Scale Factor", + category="SimpleSyrup/Primitives", + description="Provides a bounded multiplier for scaling.", + search_aliases=["scale", "scale factor", "float", "primitive"], + inputs=[ + _comfy_io.Float.Input( + "value", + default=SCALE_FACTOR_DEFAULT, + min=SCALE_FACTOR_MIN, + max=SCALE_FACTOR_MAX, + step=SCALE_FACTOR_STEP, + tooltip=tooltips.SCALE_FACTOR_VALUE, + ), + ], + outputs=[ + _comfy_io.Float.Output( + "scale_factor", + tooltip=tooltips.SCALE_FACTOR_OUTPUT, + ), + ], + ) + + @classmethod + def execute(cls, value: float) -> tuple[float]: + """Return the scale-factor value through the legacy implementation.""" + + return ScaleFactor().get_value(value=value) diff --git a/simple_syrup/nodes_v3/simple_load_checkpoint.py b/simple_syrup/nodes_v3/simple_load_checkpoint.py new file mode 100644 index 0000000..469c10d --- /dev/null +++ b/simple_syrup/nodes_v3/simple_load_checkpoint.py @@ -0,0 +1,99 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node wrapper for checkpoint loading with optional VAE override.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes import tooltips +from ..nodes.simple_load_checkpoint import SimpleLoadCheckpoint + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class SimpleLoadCheckpointV3(_ComfyNodeBase): + """Expose Simple Load Checkpoint through Comfy's v3 extension API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the Simple Load Checkpoint v3 schema.""" + + required = SimpleLoadCheckpoint.INPUT_TYPES()["required"] + checkpoint_choices = list(required["ckpt_name"][0]) + vae_options = required["vae_name"][1] + vae_choices = list(required["vae_name"][0]) + vae_default = str(vae_options["default"]) + clip_skip_options = required["clip_skip"][1] + clip_skip_default = clip_skip_options["default"] + if not isinstance(clip_skip_default, bool): + raise TypeError("clip_skip default must be a boolean.") + + return _comfy_io.Schema( + node_id="SimpleSyrup.SimpleLoadCheckpoint", + display_name="Simple Load Checkpoint", + category="SimpleSyrup/Loaders", + description="Loads a checkpoint and optionally replaces its VAE.", + search_aliases=["load checkpoint", "checkpoint", "ckpt", "vae"], + inputs=[ + _comfy_io.Combo.Input( + "ckpt_name", + options=checkpoint_choices, + tooltip=tooltips.CHECKPOINT_MODEL_INPUT, + ), + _comfy_io.Combo.Input( + "vae_name", + options=vae_choices, + default=vae_default, + tooltip=tooltips.CHECKPOINT_VAE_INPUT, + ), + _comfy_io.Boolean.Input( + "clip_skip", + default=clip_skip_default, + tooltip=tooltips.CLIP_SKIP_INPUT, + ), + ], + outputs=[ + _comfy_io.Model.Output( + "model", + tooltip=tooltips.MODEL_OUTPUT, + ), + _comfy_io.Clip.Output( + "clip", + tooltip=tooltips.CLIP_OUTPUT, + ), + _comfy_io.Vae.Output( + "vae", + tooltip=tooltips.VAE_OUTPUT, + ), + ], + ) + + @classmethod + def execute( + cls, + ckpt_name: str, + vae_name: str, + clip_skip: bool, + ) -> tuple[object, object, object]: + """Run the legacy loader implementation behind the v3 schema.""" + + return SimpleLoadCheckpoint().load_checkpoint( + ckpt_name=ckpt_name, + vae_name=vae_name, + clip_skip=clip_skip, + ) diff --git a/simple_syrup/nodes_v3/tile_and_tag_segs.py b/simple_syrup/nodes_v3/tile_and_tag_segs.py new file mode 100644 index 0000000..5ff0b99 --- /dev/null +++ b/simple_syrup/nodes_v3/tile_and_tag_segs.py @@ -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 + +"""Comfy v3 node wrapper for Tile & Tag SEGS.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..domain.tile_segs import IRREGULAR_MASK_MODES +from ..nodes import tooltips +from ..nodes.tile_and_tag_segs import DEFAULT_EXCLUDE_TAGS, TileAndTagSEGS + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io +ConditioningBatchIO: Any = ( + None if TYPE_CHECKING else _comfy_io.Custom("CONDITIONING_BATCH") +) +WD14TaggerIO: Any = None if TYPE_CHECKING else _comfy_io.Custom("WD14_TAGGER") + + +class TileAndTagSEGSV3(_ComfyNodeBase): + """Expose Tile & Tag SEGS through Comfy's v3 extension API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the Tile & Tag SEGS v3 schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.TileAndTagSEGS", + display_name="Tile & Tag SEGS", + category="SimpleSyrup/Detailing", + description=( + "Creates tile SEGS, tags each tile with a connected WD14 tagger, " + "and returns aligned conditioning for SEGS detailing." + ), + search_aliases=["tile", "tag", "wd14", "segs", "detail"], + inputs=[ + _comfy_io.Image.Input("image", tooltip=tooltips.TILE_IMAGE), + _comfy_io.Clip.Input( + "clip", + tooltip=tooltips.TILE_CLIP, + ), + WD14TaggerIO.Input( + "wd14_tagger", + tooltip=tooltips.TILE_WD14_TAGGER, + ), + _comfy_io.String.Input( + "universal_positive", + multiline=False, + default="", + tooltip=tooltips.TILE_UNIVERSAL_POSITIVE, + ), + _comfy_io.Int.Input( + "bbox_size", + default=872, + min=64, + max=4096, + step=8, + tooltip=tooltips.TILE_BBOX_SIZE, + ), + _comfy_io.Float.Input( + "crop_factor", + default=1.1, + min=1.0, + max=10.0, + step=0.01, + tooltip=tooltips.TILE_CROP_FACTOR, + ), + _comfy_io.Int.Input( + "min_overlap", + default=16, + min=0, + max=512, + step=1, + tooltip=tooltips.TILE_MIN_OVERLAP, + ), + _comfy_io.Int.Input( + "filter_segs_dilation", + default=20, + min=-255, + max=255, + step=1, + tooltip=tooltips.TILE_FILTER_SEGS_DILATION, + ), + _comfy_io.Float.Input( + "mask_irregularity", + default=0.0, + min=0.0, + max=1.0, + step=0.01, + tooltip=tooltips.TILE_MASK_IRREGULARITY, + ), + _comfy_io.Combo.Input( + "irregular_mask_mode", + options=list(IRREGULAR_MASK_MODES), + default="Reuse fast", + tooltip=tooltips.TILE_IRREGULAR_MASK_MODE, + ), + _comfy_io.Float.Input( + "threshold", + default=0.35, + min=0.0, + max=1.0, + step=0.05, + tooltip=tooltips.TILE_THRESHOLD, + ), + _comfy_io.Float.Input( + "character_threshold", + default=1.0, + min=0.0, + max=1.0, + step=0.05, + tooltip=tooltips.TILE_CHARACTER_THRESHOLD, + ), + _comfy_io.Boolean.Input( + "replace_underscore", + default=True, + tooltip=tooltips.TILE_REPLACE_UNDERSCORE, + ), + _comfy_io.Boolean.Input( + "trailing_comma", + default=False, + tooltip=tooltips.TILE_TRAILING_COMMA, + ), + _comfy_io.String.Input( + "exclude_tags", + multiline=False, + default=DEFAULT_EXCLUDE_TAGS, + tooltip=tooltips.TILE_EXCLUDE_TAGS, + ), + ], + outputs=[ + _comfy_io.SEGS.Output( + "segs", + tooltip=tooltips.TILE_SEGS_OUTPUT, + ), + ConditioningBatchIO.Output( + "positive", + tooltip=tooltips.TILE_POSITIVE_OUTPUT, + ), + ], + ) + + @classmethod + def execute( + cls, + image: object, + clip: Any, + wd14_tagger: object, + universal_positive: str, + bbox_size: int, + crop_factor: float, + min_overlap: int, + filter_segs_dilation: int, + mask_irregularity: float, + irregular_mask_mode: str, + threshold: float, + character_threshold: float, + replace_underscore: bool, + trailing_comma: bool, + exclude_tags: str, + ) -> tuple[object, object]: + """Run the legacy implementation behind the v3 schema.""" + + return TileAndTagSEGS().tile_and_tag( + image=image, + clip=clip, + wd14_tagger=wd14_tagger, + universal_positive=universal_positive, + bbox_size=bbox_size, + crop_factor=crop_factor, + min_overlap=min_overlap, + filter_segs_dilation=filter_segs_dilation, + mask_irregularity=mask_irregularity, + irregular_mask_mode=irregular_mask_mode, + threshold=threshold, + character_threshold=character_threshold, + replace_underscore=replace_underscore, + trailing_comma=trailing_comma, + exclude_tags=exclude_tags, + ) diff --git a/simple_syrup/nodes_v3/wd14_tagger_loader.py b/simple_syrup/nodes_v3/wd14_tagger_loader.py new file mode 100644 index 0000000..c49c9ad --- /dev/null +++ b/simple_syrup/nodes_v3/wd14_tagger_loader.py @@ -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 + +"""Comfy v3 node wrapper for loading WD14 taggers.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes import tooltips +from ..nodes.wd14_tagger_loader import WD14TaggerLoader +from ..runtime.model_catalog import DEFAULT_WD14_TAGGER_MODEL +from ..runtime.model_choices import ModelChoiceService, default_choice + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io +WD14TaggerIO: Any = None if TYPE_CHECKING else _comfy_io.Custom("WD14_TAGGER") + + +class WD14TaggerLoaderV3(_ComfyNodeBase): + """Expose Load WD14 Tagger through Comfy's v3 extension API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the Load WD14 Tagger v3 schema.""" + + choices = ModelChoiceService().wd14_tagger_choices() + return _comfy_io.Schema( + node_id="SimpleSyrup.WD14TaggerLoader", + display_name="Load WD14 Tagger", + category="SimpleSyrup/Tagging", + description=( + "Loads a WD14 tagger for compatible SimpleSyrup tagging workflows." + ), + search_aliases=["wd14", "tagger", "tags"], + inputs=[ + _comfy_io.Combo.Input( + "wd14_model", + options=choices, + default=default_choice(choices, DEFAULT_WD14_TAGGER_MODEL), + tooltip=tooltips.WD14_MODEL_INPUT, + ), + ], + outputs=[ + WD14TaggerIO.Output( + "wd14_tagger", + tooltip=tooltips.WD14_TAGGER_OUTPUT, + ), + ], + ) + + @classmethod + def execute(cls, wd14_model: str) -> tuple[object]: + """Run the legacy loader implementation behind the v3 schema.""" + + return WD14TaggerLoader().load_model(wd14_model=wd14_model) diff --git a/simple_syrup/runtime/__init__.py b/simple_syrup/runtime/__init__.py new file mode 100644 index 0000000..e8cae73 --- /dev/null +++ b/simple_syrup/runtime/__init__.py @@ -0,0 +1,9 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Runtime adapters for SimpleSyrup external integrations.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/simple_syrup/runtime/a1111_sampling.py b/simple_syrup/runtime/a1111_sampling.py new file mode 100644 index 0000000..669dac0 --- /dev/null +++ b/simple_syrup/runtime/a1111_sampling.py @@ -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 +# +# Portions of this file are adapted from AUTOMATIC1111 stable-diffusion-webui +# and k-diffusion. See third_party/manifest.toml and third_party/NOTICE.md. + +"""AUTOMATIC1111-derived sampler functions for SimpleSyrup sampling nodes.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from importlib import import_module +from types import ModuleType +from typing import Any, Protocol, cast + +import torch + + +class DenoiseModel(Protocol): + """Represent the k-diffusion denoiser callable used by sampler functions.""" + + def __call__( + self, + x: torch.Tensor, + sigma: torch.Tensor, + **kwargs: object, + ) -> torch.Tensor: + """Denoise a latent tensor at the requested sigma.""" + + +NoiseSampler = Callable[[torch.Tensor, torch.Tensor], torch.Tensor] +SamplerCallback = Callable[[dict[str, object]], None] + + +@torch.no_grad() +def sample_euler_ancestral_a1111( + model: DenoiseModel, + x: torch.Tensor, + sigmas: torch.Tensor, + extra_args: Mapping[str, object] | None = None, + callback: SamplerCallback | None = None, + disable: bool | None = None, + eta: float = 1.0, + s_noise: float = 1.0, + noise_sampler: NoiseSampler | None = None, +) -> torch.Tensor: + """Run A1111/k-diffusion Euler ancestral math with ComfyUI seed plumbing.""" + + del disable + normalized_extra_args = {} if extra_args is None else dict(extra_args) + active_noise_sampler = noise_sampler or _default_noise_sampler( + x, + normalized_extra_args.get("seed"), + ) + s_in = x.new_ones([x.shape[0]]) + + for index in range(len(sigmas) - 1): + denoised = model(x, sigmas[index] * s_in, **normalized_extra_args) + sigma_down, sigma_up = _get_ancestral_step( + sigmas[index], + sigmas[index + 1], + eta=eta, + ) + if callback is not None: + callback( + { + "x": x, + "i": index, + "sigma": sigmas[index], + "sigma_hat": sigmas[index], + "denoised": denoised, + } + ) + + derivative = _to_d(x, sigmas[index], denoised) + dt = sigma_down - sigmas[index] + x = x + derivative * dt + if sigmas[index + 1] > 0: + x = ( + x + + active_noise_sampler(sigmas[index], sigmas[index + 1]) + * s_noise + * sigma_up + ) + return x + + +def _default_noise_sampler(x: torch.Tensor, seed: object) -> NoiseSampler: + """Return ComfyUI's deterministic noise sampler for the active seed.""" + + try: + default_noise_sampler = cast( + Any, + _comfy_k_diffusion_sampling(), + ).default_noise_sampler + except AttributeError as error: + raise ValueError("ComfyUI default_noise_sampler is unavailable.") from error + if not callable(default_noise_sampler): + raise ValueError("ComfyUI default_noise_sampler is unavailable.") + return cast(NoiseSampler, default_noise_sampler(x, seed=seed)) + + +def _get_ancestral_step( + sigma_from: torch.Tensor, + sigma_to: torch.Tensor, + eta: float, +) -> tuple[torch.Tensor, torch.Tensor]: + """Calculate the ancestral down-step and noise scale with ComfyUI helpers.""" + + try: + get_ancestral_step = cast( + Any, + _comfy_k_diffusion_sampling(), + ).get_ancestral_step + except AttributeError as error: + raise ValueError("ComfyUI get_ancestral_step is unavailable.") from error + if not callable(get_ancestral_step): + raise ValueError("ComfyUI get_ancestral_step is unavailable.") + return cast( + tuple[torch.Tensor, torch.Tensor], + get_ancestral_step(sigma_from, sigma_to, eta=eta), + ) + + +def _to_d( + x: torch.Tensor, + sigma: torch.Tensor, + denoised: torch.Tensor, +) -> torch.Tensor: + """Convert denoised output to an Euler derivative with ComfyUI helpers.""" + + try: + to_d = cast(Any, _comfy_k_diffusion_sampling()).to_d + except AttributeError as error: + raise ValueError("ComfyUI to_d is unavailable.") from error + if not callable(to_d): + raise ValueError("ComfyUI to_d is unavailable.") + return cast(torch.Tensor, to_d(x, sigma, denoised)) + + +def _comfy_k_diffusion_sampling() -> ModuleType: + """Import ComfyUI's k-diffusion sampling helpers lazily.""" + + return import_module("comfy.k_diffusion.sampling") diff --git a/simple_syrup/runtime/anima_loader.py b/simple_syrup/runtime/anima_loader.py new file mode 100644 index 0000000..a812f99 --- /dev/null +++ b/simple_syrup/runtime/anima_loader.py @@ -0,0 +1,191 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Load Anima diffusion, text encoder, and VAE models for ComfyUI.""" + +from __future__ import annotations + +import importlib +from pathlib import Path +from types import ModuleType +from typing import Any, Protocol + +import torch + +from .auto_model_resolver import AutoModelResolution, AutoModelResolver +from .model_catalog import ANIMA_QWEN_TEXT_ENCODER, ANIMA_QWEN_VAE, AutoModelArtifact +from .model_downloads import ProgressReporter +from .vae_loader import VaeLoaderService, load_vae_path + +AUTO_CHOICE = "auto" +DIFFUSION_WEIGHT_DTYPES = ( + "default", + "fp8_e4m3fn", + "fp8_e4m3fn_fast", + "fp8_e5m2", +) +CLIP_DEVICES = ("default", "cpu") +DEFAULT_CLIP_TYPE = "stable_diffusion" + + +class AnimaLoaderService: + """Resolve and load Anima diffusion, text encoder, and VAE models.""" + + def __init__( + self, + resolver: AutoModelResolverBoundary | None = None, + folder_paths_module: ModuleType | None = None, + ) -> None: + """Create a loader with injectable auto-resolution boundaries.""" + + self._folder_paths_module = folder_paths_module + self._resolver = resolver or AutoModelResolver( + folder_paths_module=folder_paths_module + ) + self._vae_loader = VaeLoaderService(folder_paths_module) + + def load_models( + self, + diffusion_model: str, + diffusion_weight_dtype: str, + text_encoder: str, + text_encoder_device: str, + vae: str, + progress: ProgressReporter | None = None, + ) -> tuple[object, object, object]: + """Return ComfyUI MODEL, CLIP, and VAE objects.""" + + return ( + self._load_diffusion_model(diffusion_model, diffusion_weight_dtype), + self._load_clip( + text_encoder, + text_encoder_device, + progress, + ), + self._load_vae(vae, progress), + ) + + def _load_diffusion_model( + self, + diffusion_model: str, + diffusion_weight_dtype: str, + ) -> object: + """Load a diffusion model using ComfyUI's diffusion model loader policy.""" + + if diffusion_weight_dtype not in DIFFUSION_WEIGHT_DTYPES: + valid = ", ".join(DIFFUSION_WEIGHT_DTYPES) + raise ValueError(f"diffusion_weight_dtype must be one of: {valid}.") + + model_options: dict[str, object] = {} + if diffusion_weight_dtype == "fp8_e4m3fn": + model_options["dtype"] = torch.float8_e4m3fn + elif diffusion_weight_dtype == "fp8_e4m3fn_fast": + model_options["dtype"] = torch.float8_e4m3fn + model_options["fp8_optimizations"] = True + elif diffusion_weight_dtype == "fp8_e5m2": + model_options["dtype"] = torch.float8_e5m2 + + folder_paths = self._folder_paths() + unet_path = folder_paths.get_full_path_or_raise( + "diffusion_models", + diffusion_model, + ) + comfy_sd = _comfy_sd() + return comfy_sd.load_diffusion_model( + unet_path, + model_options=model_options, + ) + + def _load_clip( + self, + text_encoder: str, + text_encoder_device: str, + progress: ProgressReporter | None, + ) -> object: + """Load a CLIP/text encoder using ComfyUI's CLIP loader policy.""" + + if text_encoder_device not in CLIP_DEVICES: + valid = ", ".join(CLIP_DEVICES) + raise ValueError(f"text_encoder_device must be one of: {valid}.") + + if text_encoder == AUTO_CHOICE: + clip_path = self._resolver.resolve(ANIMA_QWEN_TEXT_ENCODER, progress).path + else: + clip_path = Path( + str( + self._folder_paths().get_full_path_or_raise( + "text_encoders", + text_encoder, + ) + ) + ) + + comfy_sd = _comfy_sd() + clip_type = getattr( + comfy_sd.CLIPType, + DEFAULT_CLIP_TYPE.upper(), + comfy_sd.CLIPType.STABLE_DIFFUSION, + ) + model_options: dict[str, object] = {} + if text_encoder_device == "cpu": + model_options["load_device"] = model_options["offload_device"] = ( + torch.device("cpu") + ) + + return comfy_sd.load_clip( + ckpt_paths=[str(clip_path)], + embedding_directory=self._folder_paths().get_folder_paths("embeddings"), + clip_type=clip_type, + model_options=model_options, + ) + + def _load_vae( + self, + vae: str, + progress: ProgressReporter | None, + ) -> object: + """Load a VAE using ComfyUI's VAE loader policy.""" + + if vae == AUTO_CHOICE: + vae_path = self._resolver.resolve(ANIMA_QWEN_VAE, progress).path + return load_vae_path(vae_path) + + return self._vae_loader.load_vae(vae) + + def _folder_paths(self) -> ModuleType: + """Return the ComfyUI folder_paths module.""" + + if self._folder_paths_module is not None: + return self._folder_paths_module + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + self._folder_paths_module = module + return module + + +class AutoModelResolverBoundary(Protocol): + """Resolver interface required by the Anima loader service.""" + + def resolve( + self, + artifact: AutoModelArtifact, + progress: ProgressReporter | None = None, + ) -> AutoModelResolution: + """Resolve one automatic model artifact.""" + + +def _folder_paths() -> ModuleType: + """Import ComfyUI folder paths lazily.""" + + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + return module + + +def _comfy_sd() -> Any: + """Import ComfyUI's stable diffusion loading module lazily.""" + + return importlib.import_module("comfy.sd") diff --git a/simple_syrup/runtime/auto_model_cache.py b/simple_syrup/runtime/auto_model_cache.py new file mode 100644 index 0000000..ea7ce5e --- /dev/null +++ b/simple_syrup/runtime/auto_model_cache.py @@ -0,0 +1,215 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Persist resolved automatic model paths under the ComfyUI user directory.""" + +from __future__ import annotations + +import importlib +import json +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType +from typing import Any, Literal, cast + +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) +CACHE_VERSION = 1 +CacheSource = Literal["cached", "found", "downloaded"] + + +class AutoModelCacheError(ValueError): + """Raised when persisted automatic model cache data is invalid.""" + + +@dataclass(frozen=True) +class AutoModelCacheEntry: + """A remembered automatic model artifact resolution.""" + + folder_name: str + filename: str + path: Path + source: CacheSource + sha256: str + + def to_payload(self) -> dict[str, str]: + """Return this cache entry as a JSON-serializable payload.""" + + return { + "folder_name": self.folder_name, + "filename": self.filename, + "path": str(self.path), + "source": self.source, + "sha256": self.sha256, + } + + @classmethod + def from_payload( + cls, + cache_id: str, + payload: object, + ) -> AutoModelCacheEntry: + """Create a cache entry from validated JSON-like data.""" + + if not isinstance(payload, dict): + raise AutoModelCacheError( + f"Auto model cache entry '{cache_id}' must be an object." + ) + folder_name = _required_string(payload, cache_id, "folder_name") + filename = _required_string(payload, cache_id, "filename") + path = _required_string(payload, cache_id, "path") + source = _required_source(payload, cache_id) + sha256 = _required_string(payload, cache_id, "sha256") + return cls( + folder_name=folder_name, + filename=filename, + path=Path(path), + source=source, + sha256=sha256, + ) + + +class AutoModelCache: + """Load and save automatic model resolutions in ComfyUI user storage.""" + + def __init__( + self, + folder_paths_module: ModuleType | None = None, + ) -> None: + """Create a cache repository with injectable ComfyUI folder paths.""" + + self._folder_paths_module = folder_paths_module + + def load(self) -> dict[str, AutoModelCacheEntry]: + """Return every valid cache entry, or an empty cache when absent.""" + + path = self.cache_path() + if not path.is_file(): + return {} + try: + payload: object = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise AutoModelCacheError( + f"Auto model cache at '{path}' is not valid JSON." + ) from error + return _entries_from_payload(payload) + + def save(self, entries: dict[str, AutoModelCacheEntry]) -> None: + """Persist all cache entries atomically.""" + + path = self.cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + payload: dict[str, object] = { + "version": CACHE_VERSION, + "entries": { + cache_id: entry.to_payload() + for cache_id, entry in sorted(entries.items()) + }, + } + temporary_path = path.with_name(f"{path.name}.tmp") + temporary_path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary_path.replace(path) + + def save_entry(self, cache_id: str, entry: AutoModelCacheEntry) -> None: + """Upsert one cache entry while preserving unrelated entries.""" + + entries = self.load() + entries[cache_id] = entry + self.save(entries) + LOGGER.info( + "auto model cache entry saved", + extra={ + "cache_id": cache_id, + "folder_name": entry.folder_name, + "path": str(entry.path), + "source": entry.source, + }, + ) + + def cache_path(self) -> Path: + """Return the JSON cache path under ComfyUI's user directory.""" + + return ( + _user_directory(self._folder_paths()) / "simple_syrup" / "auto_models.json" + ) + + def _folder_paths(self) -> ModuleType: + """Return the ComfyUI folder_paths module.""" + + if self._folder_paths_module is not None: + return self._folder_paths_module + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + self._folder_paths_module = module + return module + + +def _entries_from_payload(payload: object) -> dict[str, AutoModelCacheEntry]: + """Parse cache entries from a JSON-like object.""" + + if not isinstance(payload, dict): + raise AutoModelCacheError("Auto model cache root must be an object.") + version = payload.get("version") + if version != CACHE_VERSION: + raise AutoModelCacheError(f"Auto model cache version must be {CACHE_VERSION}.") + entries_payload = payload.get("entries") + if not isinstance(entries_payload, dict): + raise AutoModelCacheError("Auto model cache entries must be an object.") + + entries: dict[str, AutoModelCacheEntry] = {} + for cache_id, entry_payload in entries_payload.items(): + if not isinstance(cache_id, str): + raise AutoModelCacheError("Auto model cache entry keys must be strings.") + entries[cache_id] = AutoModelCacheEntry.from_payload( + cache_id, + entry_payload, + ) + return entries + + +def _required_string( + payload: dict[Any, Any], + cache_id: str, + field_name: str, +) -> str: + """Return one required string field from a cache entry payload.""" + + value = payload.get(field_name) + if not isinstance(value, str) or value == "": + raise AutoModelCacheError( + f"Auto model cache entry '{cache_id}' field '{field_name}' " + "must be a non-empty string." + ) + return value + + +def _required_source(payload: dict[Any, Any], cache_id: str) -> CacheSource: + """Return the validated source field for a cache entry payload.""" + + value = _required_string(payload, cache_id, "source") + if value not in ("cached", "found", "downloaded"): + raise AutoModelCacheError( + f"Auto model cache entry '{cache_id}' source is invalid." + ) + return cast(CacheSource, value) + + +def _user_directory(folder_paths: ModuleType) -> Path: + """Return ComfyUI's user directory using the host's current conventions.""" + + get_user_directory = getattr(folder_paths, "get_user_directory", None) + if callable(get_user_directory): + return Path(str(get_user_directory())) + + user_directory_attribute = getattr(folder_paths, "user_directory", None) + if user_directory_attribute is not None: + return Path(str(user_directory_attribute)) + + models_dir: Any = folder_paths.models_dir + return Path(str(models_dir)).parent / "user" diff --git a/simple_syrup/runtime/auto_model_resolver.py b/simple_syrup/runtime/auto_model_resolver.py new file mode 100644 index 0000000..634c643 --- /dev/null +++ b/simple_syrup/runtime/auto_model_resolver.py @@ -0,0 +1,253 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Resolve trusted automatic model artifacts from cache, disk, or download.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path, PurePosixPath, PureWindowsPath +from types import ModuleType +from typing import Protocol + +from ..shared.logging import get_logger +from .auto_model_cache import AutoModelCache, AutoModelCacheEntry, CacheSource +from .model_catalog import AutoModelArtifact +from .model_downloads import ( + DownloadRequest, + DownloadResult, + ModelDownloader, + ProgressReporter, +) +from .model_folders import get_model_folder_paths + +LOGGER = get_logger(__name__) + + +class AutoModelDownloadBoundary(Protocol): + """Downloader interface required by automatic model resolution.""" + + def download( + self, + request: DownloadRequest, + progress: ProgressReporter | None = None, + ) -> DownloadResult: + """Download one trusted artifact and return its final path.""" + + +@dataclass(frozen=True) +class AutoModelResolution: + """Resolved automatic model path and provenance.""" + + path: Path + source: CacheSource + + +class AutoModelResolver: + """Resolve known model artifacts while maintaining a self-healing cache.""" + + def __init__( + self, + cache: AutoModelCache | None = None, + downloader: AutoModelDownloadBoundary | None = None, + folder_paths_module: ModuleType | None = None, + ) -> None: + """Create a resolver with injectable persistence and download boundaries.""" + + self._folder_paths_module = folder_paths_module + self._cache = cache or AutoModelCache(folder_paths_module) + self._downloader = downloader or ModelDownloader() + + def resolve( + self, + artifact: AutoModelArtifact, + progress: ProgressReporter | None = None, + ) -> AutoModelResolution: + """Return a valid local path, downloading and caching when necessary.""" + + entries = self._cache.load() + cached = entries.get(artifact.cache_id) + if cached is not None and self._cache_entry_is_valid(cached, artifact): + LOGGER.info( + "auto model cache hit", + extra={"cache_id": artifact.cache_id, "path": str(cached.path)}, + ) + return AutoModelResolution(cached.path, "cached") + if cached is not None: + LOGGER.warning( + "auto model cache entry is stale", + extra={"cache_id": artifact.cache_id, "path": str(cached.path)}, + ) + + found = find_model_by_basename( + artifact.folder_name, + artifact.filename, + self._folder_paths_module, + ) + if found is not None: + self._save_resolution(artifact, found, "found") + LOGGER.info( + "auto model found on disk", + extra={"cache_id": artifact.cache_id, "path": str(found)}, + ) + return AutoModelResolution(found, "found") + + destination = canonical_auto_destination(artifact, self._folder_paths_module) + root = _containing_model_root( + artifact.folder_name, + destination, + self._folder_paths_module, + ) + result = self._downloader.download( + DownloadRequest( + source_url=artifact.source_url, + destination_path=destination, + expected_folder=root, + description=artifact.description, + expected_sha256=artifact.sha256, + ), + progress, + ) + self._save_resolution(artifact, result.path, "downloaded") + LOGGER.info( + "auto model downloaded", + extra={"cache_id": artifact.cache_id, "path": str(result.path)}, + ) + return AutoModelResolution(result.path, "downloaded") + + def _save_resolution( + self, + artifact: AutoModelArtifact, + path: Path, + source: CacheSource, + ) -> None: + """Persist one successful automatic model resolution.""" + + self._cache.save_entry( + artifact.cache_id, + AutoModelCacheEntry( + folder_name=artifact.folder_name, + filename=artifact.filename, + path=path, + source=source, + sha256=artifact.sha256, + ), + ) + + def _cache_entry_is_valid( + self, + entry: AutoModelCacheEntry, + artifact: AutoModelArtifact, + ) -> bool: + """Return whether a remembered entry still resolves to the artifact.""" + + if entry.folder_name != artifact.folder_name: + return False + if entry.filename != artifact.filename: + return False + if entry.sha256 != artifact.sha256: + return False + if entry.path.name != artifact.filename: + return False + if not entry.path.is_file(): + return False + try: + _containing_model_root( + artifact.folder_name, + entry.path, + self._folder_paths_module, + ) + except ValueError: + return False + return True + + +def find_model_by_basename( + folder_name: str, + basename: str, + folder_paths_module: ModuleType | None = None, +) -> Path | None: + """Return the first matching model file under registered folder paths.""" + + _validate_basename(basename) + for root in get_model_folder_paths(folder_name, folder_paths_module): + if not root.is_dir(): + continue + matches = sorted(path for path in root.rglob(basename) if path.is_file()) + for match in matches: + if match.name == basename and _path_is_under(match, root): + return match + return None + + +def canonical_auto_destination( + artifact: AutoModelArtifact, + folder_paths_module: ModuleType | None = None, +) -> Path: + """Return the canonical download path under the first registered model folder.""" + + _validate_basename(artifact.filename) + subfolder = _safe_relative_path(artifact.canonical_subfolder) + root = get_model_folder_paths(artifact.folder_name, folder_paths_module)[0] + return root / subfolder / artifact.filename + + +def relative_model_name( + folder_name: str, + path: Path, + folder_paths_module: ModuleType | None = None, +) -> str: + """Return the ComfyUI-relative filename for a resolved model path.""" + + root = _containing_model_root(folder_name, path, folder_paths_module) + return str(path.resolve().relative_to(root.resolve())) + + +def _containing_model_root( + folder_name: str, + path: Path, + folder_paths_module: ModuleType | None = None, +) -> Path: + """Return the registered model root containing a path or raise.""" + + resolved_path = path.resolve() + for root in get_model_folder_paths(folder_name, folder_paths_module): + resolved_root = root.resolve() + try: + resolved_path.relative_to(resolved_root) + except ValueError: + continue + return root + raise ValueError( + f"Model path '{path}' is outside registered '{folder_name}' folders." + ) + + +def _path_is_under(path: Path, root: Path) -> bool: + """Return whether a path resolves below a root.""" + + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def _safe_relative_path(value: str) -> Path: + """Return a safe relative path from trusted catalog metadata.""" + + path = Path(value) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"Catalog relative path '{value}' is not safe.") + return path + + +def _validate_basename(value: str) -> None: + """Reject unsafe or non-basename model filenames.""" + + paths = (Path(value), PurePosixPath(value), PureWindowsPath(value)) + if any( + path.is_absolute() or path.name != value or ".." in path.parts for path in paths + ): + raise ValueError(f"Model basename '{value}' is not safe.") diff --git a/simple_syrup/runtime/bert_resolver.py b/simple_syrup/runtime/bert_resolver.py new file mode 100644 index 0000000..5dc606e --- /dev/null +++ b/simple_syrup/runtime/bert_resolver.py @@ -0,0 +1,136 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Resolve the BERT text encoder used by GroundingDINO.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType + +from .model_catalog import BERT_ENTRY +from .model_downloads import ( + DownloadRequest, + ModelDownloader, + ProgressReporter, +) +from .model_folders import get_primary_model_folder + + +@dataclass(frozen=True) +class BertResolution: + """Resolved local BERT directory and how it was obtained.""" + + path: Path + source: str + downloaded: bool + + +class BertResolver: + """Find or download a Hugging Face-style BERT directory.""" + + def __init__( + self, + downloader: ModelDownloader | None = None, + folder_paths_module: ModuleType | None = None, + ) -> None: + """Create a resolver with injectable runtime dependencies.""" + + self._downloader = downloader or ModelDownloader() + self._folder_paths_module = folder_paths_module + + def resolve( + self, + auto_download: bool, + progress: ProgressReporter | None = None, + ) -> BertResolution: + """Return a usable BERT directory or raise an actionable error.""" + + layerstyle_path = self._models_dir() / "bert-base-uncased" + if is_valid_bert_directory(layerstyle_path): + return BertResolution( + path=layerstyle_path, + source="models/bert-base-uncased", + downloaded=False, + ) + + text_encoder_path = self._text_encoder_bert_path() + if is_valid_bert_directory(text_encoder_path): + return BertResolution( + path=text_encoder_path, + source="models/text_encoders/bert", + downloaded=False, + ) + + if not auto_download: + raise FileNotFoundError( + "BERT text encoder was not found. Checked: " + f"{layerstyle_path}; {text_encoder_path}. " + "Enable auto_download or install a Hugging Face " + "bert-base-uncased snapshot." + ) + + self._download_bert(text_encoder_path, progress) + if not is_valid_bert_directory(text_encoder_path): + raise FileNotFoundError( + f"Downloaded BERT files in '{text_encoder_path}' are incomplete." + ) + return BertResolution( + path=text_encoder_path, + source="downloaded: google-bert/bert-base-uncased", + downloaded=True, + ) + + def _download_bert( + self, + target_directory: Path, + progress: ProgressReporter | None, + ) -> None: + """Download the known BERT artifact set into the target directory.""" + + for artifact in BERT_ENTRY.artifacts: + destination = target_directory / artifact.filename + self._downloader.download( + DownloadRequest( + source_url=artifact.source_url, + destination_path=destination, + expected_folder=target_directory, + description=artifact.description, + ), + progress, + ) + + def _text_encoder_bert_path(self) -> Path: + """Return SimpleSyrup's BERT path under ComfyUI text encoders.""" + + return ( + get_primary_model_folder("text_encoders", self._folder_paths_module) + / "bert" + ) + + def _models_dir(self) -> Path: + """Return ComfyUI's models directory.""" + + import importlib + + folder_paths = self._folder_paths_module or importlib.import_module( + "folder_paths" + ) + return Path(str(folder_paths.models_dir)) + + +def is_valid_bert_directory(path: Path) -> bool: + """Return whether a directory has enough files to load BERT locally.""" + + if not path.is_dir(): + return False + has_config = (path / "config.json").is_file() + has_tokenizer = (path / "tokenizer.json").is_file() or ( + path / "vocab.txt" + ).is_file() + has_weights = (path / "model.safetensors").is_file() or ( + path / "pytorch_model.bin" + ).is_file() + return has_config and has_tokenizer and has_weights diff --git a/simple_syrup/runtime/checkpoint_loader.py b/simple_syrup/runtime/checkpoint_loader.py new file mode 100644 index 0000000..2507df2 --- /dev/null +++ b/simple_syrup/runtime/checkpoint_loader.py @@ -0,0 +1,123 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Load checkpoints with optional external VAE replacement.""" + +from __future__ import annotations + +import importlib +from collections.abc import Iterable +from types import ModuleType +from typing import Any, Protocol, cast, runtime_checkable + +from .vae_loader import VaeLoaderService + +USE_CHECKPOINT_VAE_CHOICE = "Use Checkpoint VAE" +CLIP_SKIP_DEFAULT = False +CLIP_SKIP_LAYER = -2 + + +class CheckpointLoaderService: + """Load a checkpoint and optionally adjust its CLIP and VAE outputs.""" + + def __init__( + self, + folder_paths_module: ModuleType | None = None, + vae_loader: VaeLoaderBoundary | None = None, + ) -> None: + """Create a checkpoint loader with injectable runtime boundaries.""" + + self._folder_paths_module = folder_paths_module + self._vae_loader = vae_loader or VaeLoaderService(folder_paths_module) + + def load_checkpoint( + self, + ckpt_name: str, + vae_name: str, + clip_skip: bool = CLIP_SKIP_DEFAULT, + ) -> tuple[object, object, object]: + """Return MODEL, CLIP, and selected VAE objects.""" + + _validate_clip_skip(clip_skip) + folder_paths = self._folder_paths() + ckpt_path = folder_paths.get_full_path_or_raise("checkpoints", ckpt_name) + comfy_sd = _comfy_sd() + loaded = tuple( + cast( + Iterable[object], + comfy_sd.load_checkpoint_guess_config( + ckpt_path, + output_vae=True, + output_clip=True, + embedding_directory=folder_paths.get_folder_paths("embeddings"), + ), + ) + ) + model = loaded[0] + clip = loaded[1] + checkpoint_vae = loaded[2] + selected_clip = _selected_clip(clip, clip_skip) + + if vae_name == USE_CHECKPOINT_VAE_CHOICE: + return model, selected_clip, checkpoint_vae + + return model, selected_clip, self._vae_loader.load_vae(vae_name) + + def _folder_paths(self) -> ModuleType: + """Return the ComfyUI folder_paths module.""" + + if self._folder_paths_module is not None: + return self._folder_paths_module + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + self._folder_paths_module = module + return module + + +class VaeLoaderBoundary(Protocol): + """VAE loader interface required by the checkpoint loader.""" + + def load_vae(self, vae_name: str) -> object: + """Load the named external VAE.""" + + +@runtime_checkable +class ClipLayerBoundary(Protocol): + """CLIP interface required to apply the ComfyUI clip-skip layer.""" + + def clone(self) -> ClipLayerBoundary: + """Return an independent CLIP object.""" + + def clip_layer(self, layer_idx: int) -> None: + """Set the CLIP layer index used during prompt encoding.""" + + +def _validate_clip_skip(clip_skip: object) -> None: + """Reject non-boolean clip-skip selections from runtime callers.""" + + if not isinstance(clip_skip, bool): + raise TypeError("clip_skip must be a boolean.") + + +def _selected_clip(clip: object, clip_skip: bool) -> object: + """Return the loaded CLIP or a cloned CLIP with clip skip applied.""" + + if not clip_skip: + return clip + + if not isinstance(clip, ClipLayerBoundary): + raise TypeError( + "clip_skip requires a CLIP object with clone() and clip_layer()." + ) + + selected_clip = clip.clone() + selected_clip.clip_layer(CLIP_SKIP_LAYER) + return selected_clip + + +def _comfy_sd() -> Any: + """Import ComfyUI's stable diffusion loading module lazily.""" + + return importlib.import_module("comfy.sd") diff --git a/simple_syrup/runtime/comfy_graph_provenance.py b/simple_syrup/runtime/comfy_graph_provenance.py new file mode 100644 index 0000000..1828336 --- /dev/null +++ b/simple_syrup/runtime/comfy_graph_provenance.py @@ -0,0 +1,209 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Trace ComfyUI prompt links back to unmodified VAE decode sources.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ..domain.graph_provenance import ( + BrokenProvenance, + GraphLink, + PassthroughRule, + VaeDecodeProvenance, +) + +MAX_PROVENANCE_HOPS = 128 +PASSTHROUGH_ATTRIBUTE = "GRAPH_PASSTHROUGH_OUTPUTS" + +PromptNode = Mapping[str, Any] +PromptGraph = Mapping[str, Any] +NodeRegistry = Mapping[str, type[object]] +ProvenanceTrace = VaeDecodeProvenance | BrokenProvenance + + +def trace_vae_decode_provenance( + prompt: PromptGraph, + start_link: object, + node_registry: NodeRegistry, + *, + max_hops: int = MAX_PROVENANCE_HOPS, +) -> ProvenanceTrace: + """Trace an image link through exact pass-through nodes to `VAEDecode`.""" + + current = parse_graph_link(start_link) + if current is None: + return BrokenProvenance("image input is not a graph link") + + visited: set[GraphLink] = set() + for _ in range(max_hops): + if current in visited: + return BrokenProvenance( + "provenance trace contains a cycle", + node_id=current[0], + ) + visited.add(current) + + node_id, output_slot = current + node = _prompt_node(prompt, node_id) + if node is None: + return BrokenProvenance("source node is missing", node_id=node_id) + + class_type = _class_type(node) + if class_type is None: + return BrokenProvenance( + "source node class_type is missing", + node_id=node_id, + ) + + inputs = _node_inputs(node) + if inputs is None: + return BrokenProvenance( + "source node inputs are missing", + node_id=node_id, + class_type=class_type, + ) + + if class_type == "VAEDecode": + return _trace_vae_decode(node_id, output_slot, inputs) + + class_def = node_registry.get(class_type) + if class_def is None: + return BrokenProvenance( + "source node class is not registered", + node_id=node_id, + class_type=class_type, + ) + + passthrough = _resolve_passthrough_rule(class_def, output_slot) + if isinstance(passthrough, BrokenProvenance): + return BrokenProvenance( + passthrough.reason, + node_id=node_id, + class_type=class_type, + ) + if passthrough is None: + return BrokenProvenance( + "source node does not declare exact pass-through provenance", + node_id=node_id, + class_type=class_type, + ) + + next_link = parse_graph_link(inputs.get(passthrough.input_name)) + if next_link is None: + return BrokenProvenance( + "pass-through input is not a graph link", + node_id=node_id, + class_type=class_type, + ) + current = next_link + + node_id, _ = current + return BrokenProvenance("provenance trace exceeded the hop limit", node_id=node_id) + + +def parse_graph_link(value: object) -> GraphLink | None: + """Convert a Comfy graph link value to a typed link tuple.""" + + if not isinstance(value, (list, tuple)) or len(value) != 2: + return None + node_id = value[0] + output_slot = value[1] + if not isinstance(node_id, str) or not node_id: + return None + if isinstance(output_slot, bool): + return None + if isinstance(output_slot, int): + return (node_id, output_slot) + if isinstance(output_slot, float) and output_slot.is_integer(): + return (node_id, int(output_slot)) + return None + + +def links_match(left: object, right: object) -> bool: + """Return whether two raw Comfy values refer to the same graph output.""" + + left_link = parse_graph_link(left) + right_link = parse_graph_link(right) + return left_link is not None and left_link == right_link + + +def _trace_vae_decode( + node_id: str, + output_slot: int, + inputs: Mapping[str, Any], +) -> ProvenanceTrace: + """Resolve the latent and VAE links from a `VAEDecode` prompt node.""" + + image_output = (node_id, output_slot) + if output_slot != 0: + return BrokenProvenance( + "VAEDecode output is not the image output", + node_id=node_id, + class_type="VAEDecode", + ) + + samples_link = parse_graph_link(inputs.get("samples")) + if samples_link is None: + return BrokenProvenance( + "VAEDecode samples input is not a graph link", + node_id=node_id, + class_type="VAEDecode", + ) + + return VaeDecodeProvenance( + decode_node_id=node_id, + image_output=image_output, + samples_link=samples_link, + vae_link=parse_graph_link(inputs.get("vae")), + ) + + +def _resolve_passthrough_rule( + class_def: type[object], + output_slot: int, +) -> PassthroughRule | BrokenProvenance | None: + """Return the exact pass-through rule declared by a node class.""" + + raw_rules = getattr(class_def, PASSTHROUGH_ATTRIBUTE, None) + if raw_rules is None: + return None + if not isinstance(raw_rules, Mapping): + return BrokenProvenance("pass-through metadata is malformed") + if output_slot not in raw_rules: + return None + + input_name = raw_rules[output_slot] + if not isinstance(input_name, str) or not input_name.strip(): + return BrokenProvenance("pass-through metadata is malformed") + return PassthroughRule(input_name=input_name.strip()) + + +def _prompt_node(prompt: PromptGraph, node_id: str) -> PromptNode | None: + """Return a prompt node mapping when the prompt contains one.""" + + node = prompt.get(node_id) + if not isinstance(node, Mapping): + return None + return node + + +def _class_type(node: PromptNode) -> str | None: + """Return a prompt node class type when it is valid.""" + + class_type = node.get("class_type") + if not isinstance(class_type, str) or not class_type: + return None + return class_type + + +def _node_inputs(node: PromptNode) -> Mapping[str, Any] | None: + """Return a prompt node input mapping when it is valid.""" + + inputs = node.get("inputs") + if not isinstance(inputs, Mapping): + return None + return inputs diff --git a/simple_syrup/runtime/conditioning_encoding.py b/simple_syrup/runtime/conditioning_encoding.py new file mode 100644 index 0000000..1e47f7b --- /dev/null +++ b/simple_syrup/runtime/conditioning_encoding.py @@ -0,0 +1,31 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Runtime adapters for ComfyUI conditioning encoding.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any, cast + +from ..domain.conditioning_batch import ConditioningBatch + + +class ComfyConditioningEncoder: + """Encode prompt chunks with ComfyUI's normal CLIP text encoder.""" + + def encode(self, clip: Any, text: str) -> Any: + """Encode a single prompt chunk into a normal Comfy conditioning.""" + + nodes = import_module("nodes") + return cast(Any, nodes.CLIPTextEncode().encode(clip, text)[0]) + + def encode_batch( + self, + clip: Any, + chunks: tuple[str, ...], + ) -> ConditioningBatch: + """Encode ordered prompt chunks into a conditioning batch.""" + + return ConditioningBatch(tuple(self.encode(clip, chunk) for chunk in chunks)) diff --git a/simple_syrup/runtime/detail_previews.py b/simple_syrup/runtime/detail_previews.py new file mode 100644 index 0000000..c7890a4 --- /dev/null +++ b/simple_syrup/runtime/detail_previews.py @@ -0,0 +1,485 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Full-context preview composition for detailer sampling callbacks.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from importlib import import_module +from typing import Any, Protocol, TypeAlias, cast + +import torch +from PIL import Image, ImageDraw + +from ..domain.segs import CropRegion + +DETAIL_PREVIEW_WASH_OPACITY = 0.55 +DETAIL_PREVIEW_OUTLINE_RGB = (255, 0, 0) +DETAIL_PREVIEW_OUTLINE_WIDTH = 1 + +CropBox: TypeAlias = tuple[int, int, int, int] +PreviewBytes: TypeAlias = tuple[str, Image.Image, int] +DetailPreviewCallback: TypeAlias = Callable[ + [int, torch.Tensor, torch.Tensor, int], None +] + + +class LatentPreviewer(Protocol): + """Decode latent tensors into preview images.""" + + def decode_latent_to_preview(self, x0: torch.Tensor) -> Image.Image: + """Decode a denoised latent tensor to a PIL preview image.""" + + +class ProgressBar(Protocol): + """Progress sink compatible with ComfyUI's progress bar.""" + + def update_absolute( + self, + value: int, + total: int, + preview: PreviewBytes | None = None, + ) -> None: + """Update absolute progress with optional preview image bytes.""" + + +class ProgressBarFactory(Protocol): + """Construct a ComfyUI progress bar for a sampling pass.""" + + def __call__(self, steps: int) -> ProgressBar: + """Return a progress bar for the provided step count.""" + + +class LatentPreviewModule(Protocol): + """ComfyUI latent preview module surface used by detail previews.""" + + MAX_PREVIEW_RESOLUTION: int + + def get_previewer( + self, + device: object, + latent_format: object, + ) -> LatentPreviewer | None: + """Return a latent previewer when ComfyUI previews are enabled.""" + + +class ComfyUtilsModule(Protocol): + """ComfyUI utility module surface used by detail previews.""" + + ProgressBar: ProgressBarFactory + + +class InnerModelPreviewSource(Protocol): + """Inner ComfyUI model fields needed for previewer lookup.""" + + latent_format: object + + +class ModelPreviewSource(Protocol): + """ComfyUI model fields needed for previewer lookup.""" + + load_device: object + model: InnerModelPreviewSource + + +@dataclass(frozen=True) +class DetailPreviewContext: + """Describe preview composition for one active detailer work area.""" + + image: torch.Tensor + work_region: CropRegion + work_mask: torch.Tensor + sampled_region: CropRegion | None = None + + +@dataclass(frozen=True) +class DetailPreviewGeometry: + """Map source work coordinates into preview image coordinates. + + `crop_box` uses PIL's exclusive right/bottom box convention for resizing and + pasting. `outline_box` uses inclusive right/bottom coordinates for + `ImageDraw.rectangle`. + """ + + source_size: tuple[int, int] + preview_size: tuple[int, int] + crop_box: CropBox + outline_box: CropBox + + +@dataclass(frozen=True) +class DetailPreviewCompositor: + """Compose active detail previews into a washed full-image context.""" + + geometry: DetailPreviewGeometry + washed_background: Image.Image + detail_alpha_mask: Image.Image + + @classmethod + def from_context( + cls, + context: DetailPreviewContext, + max_preview_resolution: int, + ) -> DetailPreviewCompositor: + """Create a compositor with detailer background work precomputed.""" + + source_image = _image_tensor_to_rgb_pil(context.image) + sampled_region = context.sampled_region or context.work_region + geometry = build_detail_preview_geometry( + source_width=source_image.width, + source_height=source_image.height, + crop_region=sampled_region, + max_preview_resolution=max_preview_resolution, + outline_region=context.work_region, + ) + preview_image = source_image.resize( + geometry.preview_size, + Image.Resampling.BILINEAR, + ) + wash = Image.new("RGB", preview_image.size, "black") + washed_background = Image.blend( + preview_image, + wash, + DETAIL_PREVIEW_WASH_OPACITY, + ) + left, top, right, bottom = geometry.crop_box + crop_size = (max(1, right - left), max(1, bottom - top)) + detail_alpha_mask = _detail_alpha_mask( + context.work_mask, + source_size=(source_image.width, source_image.height), + preview_size=geometry.preview_size, + sampled_box=geometry.crop_box, + target_size=crop_size, + ) + return cls( + geometry=geometry, + washed_background=washed_background, + detail_alpha_mask=detail_alpha_mask, + ) + + def compose(self, crop_preview: Image.Image) -> Image.Image: + """Return one full-context preview with the active detail pasted in.""" + + left, top, right, bottom = self.geometry.crop_box + crop_width = max(1, right - left) + crop_height = max(1, bottom - top) + resized_crop = crop_preview.convert("RGB").resize( + (crop_width, crop_height), + Image.Resampling.BILINEAR, + ) + output = self.washed_background.copy() + output.paste(resized_crop, (left, top), self.detail_alpha_mask) + ImageDraw.Draw(output).rectangle( + self.geometry.outline_box, + outline=DETAIL_PREVIEW_OUTLINE_RGB, + width=DETAIL_PREVIEW_OUTLINE_WIDTH, + ) + return output + + +def fit_preview_size(width: int, height: int, max_size: int) -> tuple[int, int]: + """Fit dimensions inside the preview limit while preserving aspect ratio.""" + + _validate_positive_int("width", width) + _validate_positive_int("height", height) + _validate_positive_int("max_size", max_size) + + long_side = max(width, height) + if long_side <= max_size: + return width, height + + scale = float(max_size) / float(long_side) + return max(1, int(round(width * scale))), max(1, int(round(height * scale))) + + +def build_detail_preview_geometry( + source_width: int, + source_height: int, + crop_region: CropRegion, + max_preview_resolution: int, + outline_region: CropRegion | None = None, +) -> DetailPreviewGeometry: + """Build preview-space boxes for the active detail work.""" + + _validate_positive_int("source_width", source_width) + _validate_positive_int("source_height", source_height) + preview_size = fit_preview_size( + source_width, + source_height, + max_preview_resolution, + ) + _validate_crop_region(crop_region, source_width, source_height) + resolved_outline_region = outline_region or crop_region + _validate_crop_region(resolved_outline_region, source_width, source_height) + + preview_width, preview_height = preview_size + scale_x = float(preview_width) / float(source_width) + scale_y = float(preview_height) / float(source_height) + crop_box = _map_crop_box(crop_region, scale_x, scale_y, preview_size) + outline_box = _map_outline_box( + resolved_outline_region, + source_width, + source_height, + scale_x, + scale_y, + preview_size, + ) + return DetailPreviewGeometry( + source_size=(source_width, source_height), + preview_size=preview_size, + crop_box=crop_box, + outline_box=outline_box, + ) + + +def work_region_from_mask(mask: torch.Tensor) -> CropRegion: + """Return the tight work region around a non-empty HW or single-item BHW mask.""" + + working = _normalize_mask_tensor(mask) + coordinates = torch.nonzero(working > 0, as_tuple=False) + if coordinates.numel() == 0: + raise ValueError("detail preview work mask must contain at least one pixel.") + top = int(coordinates[:, 0].min().item()) + bottom = int(coordinates[:, 0].max().item()) + 1 + left = int(coordinates[:, 1].min().item()) + right = int(coordinates[:, 1].max().item()) + 1 + return CropRegion(left, top, right, bottom) + + +def prepare_detail_preview_callback( + model: Any, + steps: int, + context: DetailPreviewContext, +) -> DetailPreviewCallback: + """Create a ComfyUI sampler callback with full-image detail previews.""" + + latent_preview = _latent_preview() + comfy_utils = _comfy_utils() + progress = comfy_utils.ProgressBar(steps) + previewer = latent_preview.get_previewer( + _model_load_device(model), + _model_latent_format(model), + ) + + if previewer is None: + return _progress_only_callback(progress) + + compositor = DetailPreviewCompositor.from_context( + context, + max_preview_resolution=latent_preview.MAX_PREVIEW_RESOLUTION, + ) + + def callback( + step: int, + x0: torch.Tensor, + x: torch.Tensor, + total_steps: int, + ) -> None: + """Send one composed full-context preview to ComfyUI.""" + + del x + crop_preview = previewer.decode_latent_to_preview(x0) + full_preview = compositor.compose(crop_preview) + progress.update_absolute( + step + 1, + total_steps, + ("JPEG", full_preview, latent_preview.MAX_PREVIEW_RESOLUTION), + ) + + return callback + + +def _progress_only_callback(progress: ProgressBar) -> DetailPreviewCallback: + """Create a callback that reports progress without preview images.""" + + def callback( + step: int, + x0: torch.Tensor, + x: torch.Tensor, + total_steps: int, + ) -> None: + """Update progress when ComfyUI has no active previewer.""" + + del x0, x + progress.update_absolute(step + 1, total_steps, None) + + return callback + + +def _map_crop_box( + crop_region: CropRegion, + scale_x: float, + scale_y: float, + preview_size: tuple[int, int], +) -> CropBox: + """Map a source crop to a PIL paste box with exclusive right/bottom.""" + + preview_width, preview_height = preview_size + left = _clamp(round(crop_region.left * scale_x), 0, preview_width - 1) + top = _clamp(round(crop_region.top * scale_y), 0, preview_height - 1) + right = _clamp(round(crop_region.right * scale_x), left + 1, preview_width) + bottom = _clamp(round(crop_region.bottom * scale_y), top + 1, preview_height) + return left, top, right, bottom + + +def _map_outline_box( + crop_region: CropRegion, + source_width: int, + source_height: int, + scale_x: float, + scale_y: float, + preview_size: tuple[int, int], +) -> CropBox: + """Map the preferred outside crop outline to preview coordinates.""" + + preview_width, preview_height = preview_size + source_outline = _source_outline_box(crop_region, source_width, source_height) + left = _clamp(round(source_outline[0] * scale_x), 0, preview_width - 1) + top = _clamp(round(source_outline[1] * scale_y), 0, preview_height - 1) + right = _clamp(round(source_outline[2] * scale_x), 0, preview_width - 1) + bottom = _clamp(round(source_outline[3] * scale_y), 0, preview_height - 1) + if right < left: + right = left + if bottom < top: + bottom = top + return left, top, right, bottom + + +def _source_outline_box( + crop_region: CropRegion, + source_width: int, + source_height: int, +) -> CropBox: + """Return source-space outline coordinates, preferring outside placement.""" + + return ( + crop_region.left - 1 if crop_region.left > 0 else crop_region.left, + crop_region.top - 1 if crop_region.top > 0 else crop_region.top, + crop_region.right + if crop_region.right < source_width + else crop_region.right - 1, + crop_region.bottom + if crop_region.bottom < source_height + else crop_region.bottom - 1, + ) + + +def _image_tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image: + """Convert a single-image BHWC tensor to an RGB PIL image.""" + + import numpy as np + + if image.ndim != 4: + raise ValueError("detail preview image must be a BHWC tensor.") + if int(image.shape[0]) != 1: + raise ValueError("detail preview image must contain exactly one image.") + if int(image.shape[-1]) < 1: + raise ValueError("detail preview image must contain at least one channel.") + + array = image[0].detach().cpu().float().clamp(0.0, 1.0).numpy() + if array.shape[-1] == 1: + array = np.repeat(array, 3, axis=-1) + elif array.shape[-1] >= 3: + array = array[..., :3] + else: + array = np.repeat(array[..., :1], 3, axis=-1) + return Image.fromarray((array * 255.0).round().astype(np.uint8)) + + +def _mask_tensor_to_l_pil(mask: torch.Tensor) -> Image.Image: + """Convert an HW or single-item BHW mask tensor to a grayscale alpha image.""" + + import numpy as np + + working = _normalize_mask_tensor(mask).detach().cpu().clamp(0.0, 1.0) + return Image.fromarray((working.numpy() * 255.0).round().astype(np.uint8)) + + +def _normalize_mask_tensor(mask: torch.Tensor) -> torch.Tensor: + """Normalize an HW or single-item BHW mask tensor to HW float.""" + + working = mask.detach().float() + if working.ndim == 3 and int(working.shape[0]) == 1: + working = working[0] + if working.ndim != 2: + raise ValueError("detail preview work mask must be an HW tensor.") + return working + + +def _detail_alpha_mask( + mask: torch.Tensor, + *, + source_size: tuple[int, int], + preview_size: tuple[int, int], + sampled_box: CropBox, + target_size: tuple[int, int], +) -> Image.Image: + """Return an alpha mask aligned to the sampled preview paste box.""" + + mask_image = _mask_tensor_to_l_pil(mask) + if mask_image.size == source_size: + preview_mask = mask_image.resize(preview_size, Image.Resampling.BILINEAR) + return preview_mask.crop(sampled_box).resize( + target_size, + Image.Resampling.BILINEAR, + ) + return mask_image.resize(target_size, Image.Resampling.BILINEAR) + + +def _validate_crop_region( + crop_region: CropRegion, + source_width: int, + source_height: int, +) -> None: + """Reject crop regions that cannot be mapped into the source image.""" + + if crop_region.left < 0 or crop_region.top < 0: + raise ValueError("crop_region left and top must be non-negative.") + if crop_region.right <= crop_region.left or crop_region.bottom <= crop_region.top: + raise ValueError("crop_region right/bottom must be greater than left/top.") + if crop_region.right > source_width or crop_region.bottom > source_height: + raise ValueError("crop_region must fit within the source image.") + + +def _validate_positive_int(name: str, value: int) -> None: + """Reject non-positive integer values.""" + + if int(value) <= 0: + raise ValueError(f"{name} must be greater than 0.") + + +def _clamp(value: int, minimum: int, maximum: int) -> int: + """Clamp an integer inside inclusive bounds.""" + + if minimum > maximum: + return minimum + return min(max(value, minimum), maximum) + + +def _model_load_device(model: Any) -> object: + """Return the ComfyUI model load device for previewer lookup.""" + + preview_model = cast(ModelPreviewSource, model) + return preview_model.load_device + + +def _model_latent_format(model: Any) -> object: + """Return the ComfyUI model latent format for previewer lookup.""" + + preview_model = cast(ModelPreviewSource, model) + return preview_model.model.latent_format + + +def _latent_preview() -> LatentPreviewModule: + """Import ComfyUI latent preview support lazily.""" + + return cast(LatentPreviewModule, import_module("latent_preview")) + + +def _comfy_utils() -> ComfyUtilsModule: + """Import ComfyUI utility support lazily.""" + + return cast(ComfyUtilsModule, import_module("comfy.utils")) diff --git a/simple_syrup/runtime/detail_resize.py b/simple_syrup/runtime/detail_resize.py new file mode 100644 index 0000000..c334da0 --- /dev/null +++ b/simple_syrup/runtime/detail_resize.py @@ -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 + +"""Runtime image resizing policy for detailer scaling.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Protocol + +import torch + +from .image_resamplers import NativeComfyResampler, validate_sampling +from .torchlanc_resampler import TorchLanczosResampler + +SUPPORTED_DETAIL_UPSCALE_METHODS = ( + "nearest-exact", + "bilinear", + "area", + "bicubic", + "lanczos", +) +TORCHLANC_SINC_WINDOW = 3 +TORCHLANC_PRECISION = "fp32" + + +class DetailNativeResampler(Protocol): + """Resize BCHW tensors with a selected non-Lanczos method.""" + + def resize( + self, + samples: torch.Tensor, + width: int, + height: int, + sampling: str, + ) -> torch.Tensor: + """Return resized BCHW samples.""" + + +class DetailLanczosResampler(Protocol): + """Resize BCHW tensors with TorchLanc Lanczos.""" + + def resize( + self, + samples: torch.Tensor, + width: int, + height: int, + sinc_window: int, + precision: str, + ) -> torch.Tensor: + """Return resized BCHW samples.""" + + +NativeResamplerFactory = Callable[[], DetailNativeResampler] +LanczosResamplerFactory = Callable[[], DetailLanczosResampler] + + +class DetailImageResizer: + """Resize detailer image tensors with the project detailer policy.""" + + def __init__( + self, + native_resampler_factory: NativeResamplerFactory | None = None, + lanczos_resampler_factory: LanczosResamplerFactory | None = None, + ) -> None: + """Create the resizer with injectable runtime adapters.""" + + self._native_resampler_factory = native_resampler_factory + self._lanczos_resampler_factory = lanczos_resampler_factory + + def resize_up( + self, + image: torch.Tensor, + height: int, + width: int, + method: str, + ) -> torch.Tensor: + """Resize a BHWC image upward using the requested detailer method.""" + + self.validate_method(method) + return ( + self._resize_lanczos(image, height, width) + if method == "lanczos" + else (self._resize_native(image, height, width, method)) + ) + + def resize_down_lanczos( + self, + image: torch.Tensor, + height: int, + width: int, + ) -> torch.Tensor: + """Resize a BHWC image downward using fixed TorchLanc Lanczos.""" + + return self._resize_lanczos(image, height, width) + + def validate_method(self, method: str) -> None: + """Reject unsupported detailer upscale methods.""" + + validate_sampling(method) + if method not in SUPPORTED_DETAIL_UPSCALE_METHODS: + supported = ", ".join(SUPPORTED_DETAIL_UPSCALE_METHODS) + raise ValueError( + f"Unsupported detailer upscale method {method!r}. Use one of: " + f"{supported}." + ) + + def _resize_native( + self, + image: torch.Tensor, + height: int, + width: int, + method: str, + ) -> torch.Tensor: + """Resize a BHWC image through ComfyUI's GPU resize path.""" + + samples = _bhwc_to_bchw(image) + resized = self._native_resampler().resize(samples, width, height, method) + return _bchw_to_bhwc(resized) + + def _resize_lanczos( + self, + image: torch.Tensor, + height: int, + width: int, + ) -> torch.Tensor: + """Resize a BHWC image through TorchLanc Lanczos.""" + + samples = _bhwc_to_bchw(image) + resized = self._lanczos_resampler().resize( + samples, + width, + height, + TORCHLANC_SINC_WINDOW, + TORCHLANC_PRECISION, + ) + return _bchw_to_bhwc(resized) + + def _native_resampler(self) -> DetailNativeResampler: + """Return the injected or default GPU native resampler.""" + + if self._native_resampler_factory is not None: + return self._native_resampler_factory() + return NativeComfyResampler("gpu") + + def _lanczos_resampler(self) -> DetailLanczosResampler: + """Return the injected or default TorchLanc resampler.""" + + if self._lanczos_resampler_factory is not None: + return self._lanczos_resampler_factory() + return TorchLanczosResampler() + + +def _bhwc_to_bchw(image: torch.Tensor) -> torch.Tensor: + """Validate and convert a BHWC image tensor to BCHW.""" + + if image.ndim != 4: + raise ValueError("detailer image resizing requires a BHWC tensor.") + if int(image.shape[0]) < 1 or int(image.shape[-1]) < 1: + raise ValueError("detailer image resizing requires non-empty image tensors.") + return image.float().clamp(0.0, 1.0).movedim(-1, 1) + + +def _bchw_to_bhwc(samples: torch.Tensor) -> torch.Tensor: + """Validate and convert a BCHW image tensor to BHWC.""" + + if samples.ndim != 4: + raise ValueError("detailer resampler output must be a BCHW tensor.") + return samples.movedim(1, -1).clamp(0.0, 1.0) diff --git a/simple_syrup/runtime/detail_sampling.py b/simple_syrup/runtime/detail_sampling.py new file mode 100644 index 0000000..675df09 --- /dev/null +++ b/simple_syrup/runtime/detail_sampling.py @@ -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 + +"""ComfyUI VAE and sampling adapters for scale-factor detailing.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any, TypeAlias, cast + +import torch + +from . import sampling_samplers, sampling_schedulers +from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback + +Latent: TypeAlias = dict[str, Any] + + +class DetailSampler: + """Adapt ComfyUI VAE and sampler APIs behind a testable boundary.""" + + def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent: + """Encode pixels into a ComfyUI latent dictionary.""" + + if tiled: + nodes = _nodes() + return cast( + Latent, + nodes.VAEEncodeTiled().encode(vae, pixels, 512, 64)[0], + ) + return cast(Latent, _nodes().VAEEncode().encode(vae, pixels)[0]) + + def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor: + """Decode a ComfyUI latent dictionary into pixels.""" + + if tiled: + nodes = _nodes() + return cast( + torch.Tensor, + nodes.VAEDecodeTiled().decode(vae, latent, 512, 64)[0], + ) + return cast(torch.Tensor, _nodes().VAEDecode().decode(vae, latent)[0]) + + def sample( + self, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Sample a latent with SimpleSyrup's sampler and scheduler helpers.""" + + sampler = sampling_samplers.resolve_sampler(sampler_name) + sigmas = sampling_schedulers.calculate_sigmas( + model=model, + scheduler_name=scheduler, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + ).to(model.load_device) + + latent_samples = cast(torch.Tensor, latent_image["samples"]) + comfy_sample = _comfy_sample() + comfy_utils = _comfy_utils() + + latent_samples = comfy_sample.fix_empty_latent_channels( + model, + latent_samples, + latent_image.get("downscale_ratio_spacial", None), + ) + batch_inds = ( + latent_image["batch_index"] if "batch_index" in latent_image else None + ) + noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds) + noise_mask = latent_image.get("noise_mask", None) + if preview_context is None: + callback = _latent_preview().prepare_callback(model, steps) + else: + callback = prepare_detail_preview_callback(model, steps, preview_context) + samples = comfy_sample.sample_custom( + model, + noise, + cfg, + sampler, + sigmas, + positive, + negative, + latent_samples, + noise_mask=noise_mask, + callback=callback, + disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED, + seed=seed, + ) + + output = latent_image.copy() + output.pop("downscale_ratio_spacial", None) + output["samples"] = samples + return output + + def apply_differential_diffusion(self, model: Any) -> Any: + """Patch a model for feathered denoise masks when ComfyUI supports it.""" + + options = getattr(model, "model_options", {}) + if ( + isinstance(options, dict) + and options.get("denoise_mask_function") is not None + ): + return model + + module = import_module("comfy_extras.nodes_differential_diffusion") + node = module.DifferentialDiffusion + output = node.execute(model, 1.0) + if hasattr(output, "result"): + return output.result[0] + if isinstance(output, tuple): + return output[0] + return output[0] + + +def _nodes() -> Any: + """Import ComfyUI core nodes lazily.""" + + return import_module("nodes") + + +def _comfy_sample() -> Any: + """Import ComfyUI sampling helpers lazily.""" + + import comfy.sample + + return comfy.sample + + +def _comfy_utils() -> Any: + """Import ComfyUI utility state lazily.""" + + import comfy.utils + + return comfy.utils + + +def _latent_preview() -> Any: + """Import ComfyUI preview helpers lazily.""" + + return import_module("latent_preview") diff --git a/simple_syrup/runtime/detector_compat.py b/simple_syrup/runtime/detector_compat.py new file mode 100644 index 0000000..3a9fd79 --- /dev/null +++ b/simple_syrup/runtime/detector_compat.py @@ -0,0 +1,77 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Impact-style detector facades backed by native SimpleSyrup services.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..domain.segs import to_impact_compatible_segs +from .ultralytics_loader import UltralyticsDetectorModel + + +@dataclass(frozen=True) +class BBoxDetectorFacade: + """Expose a bbox detector-shaped object for existing workflows.""" + + detector_model: UltralyticsDetectorModel + + def detect( + self, + image: object, + threshold: float, + dilation: int, + crop_factor: float, + drop_size: int = 1, + detailer_hook: object | None = None, + ) -> object: + """Detect rectangular SEGS through the native detection service.""" + + del detailer_hook + from ..services.segs_detection_service import SegsDetectionService + + segs = SegsDetectionService().detect( + image=image, + detector_model=self.detector_model, + threshold=threshold, + dilation=dilation, + crop_factor=crop_factor, + drop_size=drop_size, + prefer_segmentation=False, + ) + return to_impact_compatible_segs(segs) + + +@dataclass(frozen=True) +class SegmDetectorFacade: + """Expose a segmentation detector-shaped object with bbox fallback.""" + + detector_model: UltralyticsDetectorModel + bbox_detector: BBoxDetectorFacade + + def detect( + self, + image: object, + threshold: float, + dilation: int, + crop_factor: float, + drop_size: int = 1, + detailer_hook: object | None = None, + ) -> object: + """Detect segmentation SEGS when available, otherwise rectangular SEGS.""" + + del detailer_hook + from ..services.segs_detection_service import SegsDetectionService + + segs = SegsDetectionService().detect( + image=image, + detector_model=self.detector_model, + threshold=threshold, + dilation=dilation, + crop_factor=crop_factor, + drop_size=drop_size, + prefer_segmentation=self.detector_model.supports_segmentation, + ) + return to_impact_compatible_segs(segs) diff --git a/simple_syrup/runtime/grounding_dino_loader.py b/simple_syrup/runtime/grounding_dino_loader.py new file mode 100644 index 0000000..4f6bbd5 --- /dev/null +++ b/simple_syrup/runtime/grounding_dino_loader.py @@ -0,0 +1,365 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Load GroundingDINO models and explicit text encoders.""" + +from __future__ import annotations + +import importlib +from collections.abc import MutableMapping +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType + +import torch + +from ..shared.logging import get_logger +from .bert_resolver import is_valid_bert_directory +from .loaded_models import LoadedGroundingDINOModel +from .model_catalog import BERT_ENTRY, ModelEntry, get_grounding_dino_entry +from .model_device_manager import TorchModelDeviceManager +from .model_downloads import DownloadRequest, ModelDownloader, ProgressReporter +from .model_folders import ( + expected_model_file, + get_primary_model_folder, + register_required_model_folders, + resolve_model_file, +) +from .model_instance_cache import ModelInstanceCache + +LOGGER = get_logger(__name__) +GROUNDING_DINO_RUNTIME_PACKAGE = "simple_syrup.third_party.groundingdino_runtime" +TEXT_ENCODER_LAYERSTYLE = "LayerStyle bert-base-uncased" +TEXT_ENCODER_COMFY = "text_encoders/bert" +TEXT_ENCODER_AUTO = "BERT base uncased (auto)" +TEXT_ENCODER_CHOICES = ( + TEXT_ENCODER_LAYERSTYLE, + TEXT_ENCODER_COMFY, + TEXT_ENCODER_AUTO, +) + + +@dataclass(frozen=True) +class TextEncoderResolution: + """Resolved text encoder directory and selection metadata.""" + + path: Path + source: str + downloaded: bool + + +@dataclass(frozen=True) +class GroundingDINOModelCacheKey: + """Identify a loaded GroundingDINO model for process-level reuse.""" + + model_id: str + config_path: Path + checkpoint_path: Path + text_encoder_path: Path + + +_LOADED_GROUNDING_DINO_MODELS: dict[ + GroundingDINOModelCacheKey, LoadedGroundingDINOModel +] = {} + + +class GroundingDINOLoaderService: + """Resolve, download, and load GroundingDINO with an explicit text encoder.""" + + def __init__( + self, + downloader: ModelDownloader | None = None, + folder_paths_module: ModuleType | None = None, + device_manager: TorchModelDeviceManager | None = None, + cache: ( + MutableMapping[GroundingDINOModelCacheKey, LoadedGroundingDINOModel] | None + ) = None, + ) -> None: + """Create a GroundingDINO loader with injectable external boundaries.""" + + self._downloader = downloader or ModelDownloader() + self._folder_paths_module = folder_paths_module + self._device_manager = device_manager or TorchModelDeviceManager() + self._cache: ModelInstanceCache[ + GroundingDINOModelCacheKey, LoadedGroundingDINOModel + ] = ModelInstanceCache( + cache if cache is not None else _LOADED_GROUNDING_DINO_MODELS + ) + + def load_model( + self, + grounding_dino_model: str, + text_encoder: str, + auto_download: bool, + progress: ProgressReporter | None = None, + ) -> LoadedGroundingDINOModel: + """Load GroundingDINO and return a `GROUNDING_DINO_MODEL` object.""" + + register_required_model_folders(self._folder_paths_module) + entry = get_grounding_dino_entry(grounding_dino_model) + artifact_paths = self._resolve_artifacts(entry, auto_download, progress) + text_encoder_resolution = self.resolve_text_encoder( + text_encoder, + auto_download, + progress, + ) + config_path = _artifact_path_with_suffix(artifact_paths, ".py") + checkpoint_path = _artifact_path_with_suffix(artifact_paths, ".pth") + key = GroundingDINOModelCacheKey( + model_id=entry.entry_id, + config_path=config_path.resolve(), + checkpoint_path=checkpoint_path.resolve(), + text_encoder_path=text_encoder_resolution.path.resolve(), + ) + already_loaded = key in self._cache.entries + loaded = self._cache.get_or_load( + key, + lambda: self._load_uncached_model( + entry, + config_path, + checkpoint_path, + text_encoder_resolution, + ), + ) + if already_loaded: + LOGGER.info( + "GroundingDINO model loaded from process cache", + extra={ + "operation": "grounding_dino_loader", + "model": entry.entry_id, + "config_path": str(config_path), + "checkpoint_path": str(checkpoint_path), + "text_encoder_path": str(text_encoder_resolution.path), + }, + ) + return loaded + + def _load_uncached_model( + self, + entry: ModelEntry, + config_path: Path, + checkpoint_path: Path, + text_encoder_resolution: TextEncoderResolution, + ) -> LoadedGroundingDINOModel: + """Load and wrap GroundingDINO after resolution and cache lookup.""" + + model = self._load_grounding_dino_model( + entry, + config_path, + checkpoint_path, + text_encoder_resolution.path, + ) + managed_model = self._device_manager.manage( + model, + model_id=entry.entry_id, + source=str(checkpoint_path), + ) + loaded = LoadedGroundingDINOModel( + model=model, + text_encoder_path=text_encoder_resolution.path, + source=text_encoder_resolution.source, + model_id=entry.entry_id, + managed_model=managed_model, + ) + LOGGER.info( + "GroundingDINO model loaded", + extra={ + "operation": "grounding_dino_loader", + "model": entry.entry_id, + "config_path": str(config_path), + "checkpoint_path": str(checkpoint_path), + "text_encoder_path": str(text_encoder_resolution.path), + }, + ) + return loaded + + def resolve_text_encoder( + self, + text_encoder: str, + auto_download: bool, + progress: ProgressReporter | None = None, + ) -> TextEncoderResolution: + """Resolve the selected BERT text encoder mode.""" + + layerstyle_path = self._models_dir() / "bert-base-uncased" + comfy_path = self._text_encoder_bert_path() + + if text_encoder == TEXT_ENCODER_LAYERSTYLE: + return _require_bert_directory(layerstyle_path, text_encoder) + if text_encoder == TEXT_ENCODER_COMFY: + return _require_bert_directory(comfy_path, text_encoder) + if text_encoder != TEXT_ENCODER_AUTO: + valid = ", ".join(TEXT_ENCODER_CHOICES) + raise ValueError(f"text_encoder must be one of: {valid}.") + + if is_valid_bert_directory(layerstyle_path): + return TextEncoderResolution( + path=layerstyle_path, + source="models/bert-base-uncased", + downloaded=False, + ) + if is_valid_bert_directory(comfy_path): + return TextEncoderResolution( + path=comfy_path, + source="models/text_encoders/bert", + downloaded=False, + ) + if not auto_download: + raise FileNotFoundError( + "BERT text encoder was not found. Checked: " + f"{layerstyle_path}; {comfy_path}. Enable auto_download on " + "GroundingDINO Model Loader or install a Hugging Face " + "bert-base-uncased snapshot." + ) + self._download_bert(comfy_path, progress) + if not is_valid_bert_directory(comfy_path): + raise FileNotFoundError( + f"Downloaded BERT files in '{comfy_path}' are incomplete." + ) + return TextEncoderResolution( + path=comfy_path, + source="downloaded: google-bert/bert-base-uncased", + downloaded=True, + ) + + def _resolve_artifacts( + self, + entry: ModelEntry, + auto_download: bool, + progress: ProgressReporter | None, + ) -> dict[str, Path]: + """Resolve or download GroundingDINO catalog artifacts.""" + + artifact_paths: dict[str, Path] = {} + for artifact in entry.artifacts: + existing = resolve_model_file( + artifact.folder_name, + artifact.filename, + self._folder_paths_module, + ) + if existing is not None: + artifact_paths[artifact.artifact_id] = existing + continue + + destination = expected_model_file( + artifact.folder_name, + artifact.filename, + self._folder_paths_module, + ) + if not auto_download or not entry.auto_download_allowed: + raise FileNotFoundError( + f"GroundingDINO model '{entry.display_name}' is missing and " + f"auto_download is disabled. Expected: {destination}. " + "Enable auto_download on GroundingDINO Model Loader or install " + "the model artifact." + ) + result = self._downloader.download( + DownloadRequest( + source_url=artifact.source_url, + destination_path=destination, + expected_folder=destination.parent, + description=artifact.description, + ), + progress, + ) + artifact_paths[artifact.artifact_id] = result.path + return artifact_paths + + def _download_bert( + self, + target_directory: Path, + progress: ProgressReporter | None, + ) -> None: + """Download the known BERT artifact set into the selected directory.""" + + for artifact in BERT_ENTRY.artifacts: + destination = target_directory / artifact.filename + self._downloader.download( + DownloadRequest( + source_url=artifact.source_url, + destination_path=destination, + expected_folder=target_directory, + description=artifact.description, + ), + progress, + ) + + def _load_grounding_dino_model( + self, + entry: ModelEntry, + config_path: Path, + checkpoint_path: Path, + text_encoder_path: Path, + ) -> object: + """Load GroundingDINO from a known config/checkpoint pair.""" + + importlib.invalidate_caches() + try: + slconfig_module = importlib.import_module( + f"{GROUNDING_DINO_RUNTIME_PACKAGE}.util.slconfig" + ) + utils_module = importlib.import_module( + f"{GROUNDING_DINO_RUNTIME_PACKAGE}.util.utils" + ) + models_module = importlib.import_module( + f"{GROUNDING_DINO_RUNTIME_PACKAGE}.models" + ) + except ImportError as error: + raise RuntimeError( + "SimpleSyrup's GroundingDINO runtime is unavailable. Reinstall " + "SimpleSyrup or restore " + "simple_syrup.third_party.groundingdino_runtime. " + f"Import failed: {error}." + ) from error + + args = slconfig_module.SLConfig.fromfile(str(config_path)) + if getattr(args, "text_encoder_type", "") == "bert-base-uncased": + args.text_encoder_type = str(text_encoder_path) + + model = models_module.build_model(args) + checkpoint = torch.load(str(checkpoint_path), map_location="cpu") + clean_state_dict = utils_module.clean_state_dict + model.load_state_dict(clean_state_dict(checkpoint["model"]), strict=False) + model.eval() + model.model_name = entry.entry_id + return model + + def _text_encoder_bert_path(self) -> Path: + """Return SimpleSyrup's BERT path under ComfyUI text encoders.""" + + return ( + get_primary_model_folder("text_encoders", self._folder_paths_module) + / "bert" + ) + + def _models_dir(self) -> Path: + """Return ComfyUI's models directory.""" + + import importlib as _importlib + + folder_paths = self._folder_paths_module or _importlib.import_module( + "folder_paths" + ) + return Path(str(folder_paths.models_dir)) + + +def _require_bert_directory(path: Path, label: str) -> TextEncoderResolution: + """Return an explicit BERT directory or fail with an actionable message.""" + + if not is_valid_bert_directory(path): + raise FileNotFoundError( + f"Text encoder '{label}' is incomplete. Expected files under {path}: " + "config.json, tokenizer.json or vocab.txt, and model.safetensors or " + "pytorch_model.bin." + ) + return TextEncoderResolution(path=path, source=label, downloaded=False) + + +def _artifact_path_with_suffix(artifact_paths: dict[str, Path], suffix: str) -> Path: + """Return the first artifact path with a suffix.""" + + for path in artifact_paths.values(): + if path.suffix.lower() == suffix: + return path + raise FileNotFoundError(f"GroundingDINO model does not have a '{suffix}' artifact.") diff --git a/simple_syrup/runtime/image_resamplers.py b/simple_syrup/runtime/image_resamplers.py new file mode 100644 index 0000000..4999f35 --- /dev/null +++ b/simple_syrup/runtime/image_resamplers.py @@ -0,0 +1,101 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI-native image resampling adapters.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Literal, Protocol, cast + +import torch + +SUPPORTED_SAMPLERS = ("nearest-exact", "bilinear", "area", "bicubic", "lanczos") +Processor = Literal["cpu", "gpu"] +CommonUpscale = Callable[[torch.Tensor, int, int, str, str], torch.Tensor] +DeviceProvider = Callable[[], torch.device | str] + + +class ImageResampler(Protocol): + """Resize BCHW image tensors to the requested size.""" + + def resize( + self, + samples: torch.Tensor, + width: int, + height: int, + sampling: str, + ) -> torch.Tensor: + """Return resized BCHW samples.""" + + +class NativeComfyResampler: + """Resize images through ComfyUI's native Pillow/PyTorch path.""" + + def __init__( + self, + processor: Processor, + common_upscale: CommonUpscale | None = None, + device_provider: DeviceProvider | None = None, + ) -> None: + """Create a native resampler for the selected processor.""" + + if processor not in ("cpu", "gpu"): + raise ValueError(f"processor must be 'cpu' or 'gpu', got {processor!r}.") + self._processor = processor + self._common_upscale = common_upscale + self._device_provider = device_provider + + def resize( + self, + samples: torch.Tensor, + width: int, + height: int, + sampling: str, + ) -> torch.Tensor: + """Resize BCHW samples with ComfyUI's native implementation.""" + + validate_sampling(sampling) + device = torch.device("cpu") + if self._processor == "gpu": + device = self._get_torch_device() + + samples_on_device = samples.to(device=device) + return self._get_common_upscale()( + samples_on_device, + int(width), + int(height), + sampling, + "disabled", + ) + + def _get_common_upscale(self) -> CommonUpscale: + """Return ComfyUI's common upscale function or an injected test double.""" + + if self._common_upscale is not None: + return self._common_upscale + + import comfy.utils + + return cast(CommonUpscale, comfy.utils.common_upscale) + + def _get_torch_device(self) -> torch.device: + """Return ComfyUI's configured torch execution device.""" + + if self._device_provider is not None: + return torch.device(self._device_provider()) + + from comfy import model_management + + return torch.device(model_management.get_torch_device()) + + +def validate_sampling(sampling: str) -> None: + """Validate a ComfyUI image resize sampler name.""" + + if sampling not in SUPPORTED_SAMPLERS: + supported = ", ".join(SUPPORTED_SAMPLERS) + raise ValueError( + f"Unsupported sampling method {sampling!r}. Use one of: {supported}." + ) diff --git a/simple_syrup/runtime/loaded_models.py b/simple_syrup/runtime/loaded_models.py new file mode 100644 index 0000000..5353414 --- /dev/null +++ b/simple_syrup/runtime/loaded_models.py @@ -0,0 +1,99 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Loaded model containers used by SimpleSyrup masking nodes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .model_device_manager import ManagedTorchModel + from .wd14_tagger import WD14Session, WD14TagRecord + + +@dataclass(frozen=True) +class LoadedSAMModel: + """Loaded SAM-compatible model object with source metadata.""" + + model: object + source: str + model_id: str + managed_model: ManagedTorchModel | None = None + + +@dataclass(frozen=True) +class LoadedGroundingDINOModel: + """Loaded GroundingDINO-compatible model object with text encoder metadata.""" + + model: object + text_encoder_path: Path + source: str + model_id: str + managed_model: ManagedTorchModel | None = None + + +@dataclass(frozen=True) +class LoadedViTMatteModel: + """Loaded ViTMatte-compatible model object with source metadata.""" + + model: object + processor: object + source: str + model_id: str + model_path: Path + managed_model: ManagedTorchModel | None = None + + +@dataclass(frozen=True) +class LoadedWD14Tagger: + """Loaded WD14 tagger runtime shared by compatible nodes.""" + + model_id: str + source: str + onnx_path: Path + csv_path: Path + providers: tuple[str, ...] + session: WD14Session + tags: tuple[WD14TagRecord, ...] + + +def unwrap_sam_model(model: object) -> object: + """Return the underlying SAM object for SimpleSyrup loaded containers.""" + + if isinstance(model, LoadedSAMModel): + return model.model + return model + + +def unwrap_grounding_dino_model(model: object) -> object: + """Return the underlying GroundingDINO object for SimpleSyrup containers.""" + + if isinstance(model, LoadedGroundingDINOModel): + return model.model + return model + + +def unwrap_vitmatte_model(model: object) -> LoadedViTMatteModel: + """Return a validated SimpleSyrup ViTMatte model container.""" + + if isinstance(model, LoadedViTMatteModel): + return model + raise TypeError( + "VITMATTE_MODEL is not compatible with Prompt SEGS w/ SAM. Expected a " + "ViTMatte model loaded by ViTMatte Model Loader." + ) + + +def unwrap_wd14_tagger(model: object) -> LoadedWD14Tagger: + """Return a validated SimpleSyrup WD14 tagger container.""" + + if isinstance(model, LoadedWD14Tagger): + return model + raise TypeError( + "WD14_TAGGER is not compatible with this node. Expected a WD14 tagger " + "loaded by Load WD14 Tagger." + ) diff --git a/simple_syrup/runtime/mixture_of_diffusers_sampling.py b/simple_syrup/runtime/mixture_of_diffusers_sampling.py new file mode 100644 index 0000000..75633ef --- /dev/null +++ b/simple_syrup/runtime/mixture_of_diffusers_sampling.py @@ -0,0 +1,330 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Portions of this file incorporate behavior derived from +# multidiffusion-upscaler-for-automatic1111. See third_party/manifest.toml and +# third_party/NOTICE.md. + +"""ComfyUI runtime adapter for Mixture of Diffusers tiled sampling.""" + +from __future__ import annotations + +from collections.abc import Sequence +from importlib import import_module +from types import ModuleType +from typing import Any, cast + +import torch + +from ..domain.tiled_diffusion import ( + LatentTile, + TiledDiffusionPlan, + build_tiled_diffusion_plan, + gaussian_tile_weights, +) +from ..shared.logging import get_logger +from . import sampling_samplers, sampling_schedulers +from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback +from .tiled_sampling import ( + ApplyModel, + Latent, + ModelFunctionWrapper, + make_tiled_model_args, + new_spatial_weight_buffer, + reject_unsupported_conditioning, + spatial_tile_slicer, + validate_latent_samples, + validate_sampling_controls, + validate_tensor_shape, +) + +LOGGER = get_logger(__name__) +SAMPLER_LABEL = "Mixture of Diffusers" + + +def sample_mixture_of_diffusers( + *, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None = None, +) -> Latent: + """Sample a latent with a cloned model patched for Mixture of Diffusers.""" + + validate_sampling_controls( + steps=steps, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_batch_size=latent_tile_batch_size, + ) + reject_unsupported_conditioning(positive, sampler_label=SAMPLER_LABEL) + reject_unsupported_conditioning(negative, sampler_label=SAMPLER_LABEL) + + sampler = sampling_samplers.resolve_sampler(sampler_name) + sigmas = sampling_schedulers.calculate_sigmas( + model=model, + scheduler_name=scheduler, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + ).to(model.load_device) + + latent_samples = validate_latent_samples( + latent_image, + sampler_label=SAMPLER_LABEL, + ) + comfy_sample = _comfy_sample() + comfy_utils = _comfy_utils() + + latent_samples = comfy_sample.fix_empty_latent_channels( + model, + latent_samples, + latent_image.get("downscale_ratio_spacial", None), + ) + validate_tensor_shape(latent_samples, sampler_label=SAMPLER_LABEL) + latent_height = int(latent_samples.shape[-2]) + latent_width = int(latent_samples.shape[-1]) + sampling_model, plan = clone_model_with_mixture_of_diffusers( + model, + latent_width=latent_width, + latent_height=latent_height, + tile_width=latent_tile_width, + tile_height=latent_tile_height, + overlap=latent_tile_overlap, + tile_batch_size=latent_tile_batch_size, + ) + + batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None + noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds) + noise_mask = latent_image.get("noise_mask", None) + callback = _sampling_callback(sampling_model, steps, preview_context) + samples = comfy_sample.sample_custom( + sampling_model, + noise, + cfg, + sampler, + sigmas, + positive, + negative, + latent_samples, + noise_mask=noise_mask, + callback=callback, + disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED, + seed=seed, + ) + + LOGGER.info( + "KSampler Mixture of Diffusers pass completed", + extra={ + "operation": "ksampler_mixture_of_diffusers", + "sampler": sampler_name, + "scheduler": scheduler, + "steps": steps, + "denoise": denoise, + "latent_width": latent_width, + "latent_height": latent_height, + "tile_width": plan.tile_width, + "tile_height": plan.tile_height, + "overlap": plan.overlap, + "tile_count": len(plan.tiles), + "requested_tile_batch_size": plan.requested_tile_batch_size, + "tile_batch_size": plan.tile_batch_size, + }, + ) + + output = latent_image.copy() + output.pop("downscale_ratio_spacial", None) + output["samples"] = samples + return output + + +def clone_model_with_mixture_of_diffusers( + model: Any, + *, + latent_width: int, + latent_height: int, + tile_width: int, + tile_height: int, + overlap: int, + tile_batch_size: int, +) -> tuple[Any, TiledDiffusionPlan]: + """Return a model clone patched with a pre-CFG Mixture wrapper.""" + + 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, + ) + cloned_model = model.clone() + old_wrapper = cloned_model.model_options.get("model_function_wrapper") + if old_wrapper is not None and not callable(old_wrapper): + raise ValueError("Existing model_function_wrapper is not callable.") + + wrapper = MixtureOfDiffusersModelWrapper( + plan=plan, + existing_wrapper=cast(ModelFunctionWrapper | None, old_wrapper), + ) + cloned_model.set_model_unet_function_wrapper(wrapper) + return cloned_model, plan + + +class MixtureOfDiffusersModelWrapper: + """Blend tiled model predictions before ComfyUI CFG combines them.""" + + def __init__( + self, + *, + plan: TiledDiffusionPlan, + existing_wrapper: ModelFunctionWrapper | None, + ) -> None: + """Create the model wrapper for one latent sampling shape.""" + + self._plan = plan + self._existing_wrapper = existing_wrapper + self._tile_weights_2d: torch.Tensor | None = None + + def __call__( + self, + apply_model: ApplyModel, + args: dict[str, Any], + ) -> torch.Tensor: + """Run the original model on latent tiles and blend the predictions.""" + + x = args["input"] + if not isinstance(x, torch.Tensor): + raise ValueError("Mixture of Diffusers model input must be a tensor.") + validate_tensor_shape(x, sampler_label=SAMPLER_LABEL) + if x.shape[-2:] != (self._plan.latent_height, self._plan.latent_width): + return self._call_original(apply_model, args) + if len(self._plan.tiles) <= 1: + return self._call_original(apply_model, args) + + timestep = args["timestep"] + if not isinstance(timestep, torch.Tensor): + raise ValueError("Mixture of Diffusers timestep must be a tensor.") + conditioning = args.get("c", {}) + if not isinstance(conditioning, dict): + raise ValueError("Mixture of Diffusers conditioning must be a dict.") + if conditioning.get("control") is not None: + raise ValueError( + "Mixture of Diffusers does not support regional conditioning or " + "ControlNet in the first implementation." + ) + + output_buffer = torch.zeros_like(x) + weight_buffer = new_spatial_weight_buffer(x, self._plan) + input_batch_size = int(x.shape[0]) + weights = self._weights_for(x) + + for batch in self._plan.batches: + tiled_args = self._make_tiled_args( + args=args, + tiles=batch, + input_batch_size=input_batch_size, + ) + tile_output = self._call_original(apply_model, tiled_args) + for index, tile in enumerate(batch): + tile_slice = spatial_tile_slicer(tile, x.ndim) + start = index * input_batch_size + end = start + input_batch_size + output_buffer[tile_slice] += tile_output[start:end] * weights.to( + dtype=tile_output.dtype + ) + weight_buffer[tile_slice] += weights.to(dtype=weight_buffer.dtype) + + return output_buffer / weight_buffer.to(dtype=output_buffer.dtype) + + def _call_original( + self, + apply_model: ApplyModel, + args: dict[str, Any], + ) -> torch.Tensor: + """Call the preserved model wrapper or raw apply_model.""" + + if self._existing_wrapper is not None: + return self._existing_wrapper(apply_model, args) + conditioning = args.get("c", {}) + if not isinstance(conditioning, dict): + raise ValueError("Mixture of Diffusers conditioning must be a dict.") + return apply_model(args["input"], args["timestep"], **conditioning) + + def _make_tiled_args( + self, + *, + args: dict[str, Any], + tiles: Sequence[LatentTile], + input_batch_size: int, + ) -> dict[str, Any]: + """Create apply-model args for one tile batch.""" + + return make_tiled_model_args( + args=args, + tiles=tiles, + input_batch_size=input_batch_size, + latent_height=self._plan.latent_height, + latent_width=self._plan.latent_width, + ) + + def _weights_for(self, x: torch.Tensor) -> torch.Tensor: + """Return cached Gaussian tile weights for the active device and dtype.""" + + if ( + self._tile_weights_2d is None + or self._tile_weights_2d.device != x.device + or self._tile_weights_2d.dtype != x.dtype + ): + self._tile_weights_2d = gaussian_tile_weights( + self._plan.tile_width, + self._plan.tile_height, + device=x.device, + dtype=x.dtype, + ) + return self._tile_weights_2d.reshape( + (1,) * (x.ndim - 2) + (self._plan.tile_height, self._plan.tile_width) + ) + + +def _comfy_sample() -> ModuleType: + """Import ComfyUI sample helpers lazily.""" + + return import_module("comfy.sample") + + +def _sampling_callback( + model: Any, + steps: int, + preview_context: DetailPreviewContext | None, +) -> Any: + """Return a generic or detailer-specific sampling preview callback.""" + + if preview_context is None: + return _latent_preview().prepare_callback(model, steps) + return prepare_detail_preview_callback(model, steps, preview_context) + + +def _comfy_utils() -> ModuleType: + """Import ComfyUI utility state lazily.""" + + return import_module("comfy.utils") + + +def _latent_preview() -> ModuleType: + """Import ComfyUI preview helpers lazily.""" + + return import_module("latent_preview") diff --git a/simple_syrup/runtime/model_catalog.py b/simple_syrup/runtime/model_catalog.py new file mode 100644 index 0000000..f8a8159 --- /dev/null +++ b/simple_syrup/runtime/model_catalog.py @@ -0,0 +1,475 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Known model metadata for grounded SAM masking.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class ModelFamily(StrEnum): + """Catalog families used by grounded SAM model selection.""" + + SAM = "sam" + GROUNDING_DINO = "grounding_dino" + TEXT_ENCODER = "text_encoder" + VITMATTE = "vitmatte" + WD14_TAGGER = "wd14_tagger" + + +@dataclass(frozen=True) +class ModelArtifact: + """A downloadable file required by a known model entry.""" + + artifact_id: str + filename: str + folder_name: str + source_url: str + description: str + + +@dataclass(frozen=True) +class AutoModelArtifact: + """A trusted auto-resolved model artifact with a canonical destination.""" + + cache_id: str + filename: str + folder_name: str + canonical_subfolder: str + source_url: str + source_repo: str + description: str + sha256: str + + +@dataclass(frozen=True) +class ModelEntry: + """A known model selection and its source metadata.""" + + entry_id: str + display_name: str + family: ModelFamily + model_type: str + artifacts: tuple[ModelArtifact, ...] + source_repo: str + auto_download_allowed: bool = True + license_note: str = "" + + +SAM_ENTRIES: tuple[ModelEntry, ...] = ( + ModelEntry( + entry_id="sam_vit_h", + display_name="sam_vit_h (2.56GB)", + family=ModelFamily.SAM, + model_type="vit_h", + source_repo="facebookresearch/segment-anything", + artifacts=( + ModelArtifact( + artifact_id="sam_vit_h_checkpoint", + filename="sam_vit_h_4b8939.pth", + folder_name="sams", + source_url=( + "https://dl.fbaipublicfiles.com/segment_anything/" + "sam_vit_h_4b8939.pth" + ), + description="SAM ViT-H checkpoint", + ), + ), + ), + ModelEntry( + entry_id="sam_vit_l", + display_name="sam_vit_l (1.25GB)", + family=ModelFamily.SAM, + model_type="vit_l", + source_repo="facebookresearch/segment-anything", + artifacts=( + ModelArtifact( + artifact_id="sam_vit_l_checkpoint", + filename="sam_vit_l_0b3195.pth", + folder_name="sams", + source_url=( + "https://dl.fbaipublicfiles.com/segment_anything/" + "sam_vit_l_0b3195.pth" + ), + description="SAM ViT-L checkpoint", + ), + ), + ), + ModelEntry( + entry_id="sam_vit_b", + display_name="sam_vit_b (375MB)", + family=ModelFamily.SAM, + model_type="vit_b", + source_repo="facebookresearch/segment-anything", + artifacts=( + ModelArtifact( + artifact_id="sam_vit_b_checkpoint", + filename="sam_vit_b_01ec64.pth", + folder_name="sams", + source_url=( + "https://dl.fbaipublicfiles.com/segment_anything/" + "sam_vit_b_01ec64.pth" + ), + description="SAM ViT-B checkpoint", + ), + ), + ), + ModelEntry( + entry_id="sam_hq_vit_h", + display_name="sam_hq_vit_h (2.57GB)", + family=ModelFamily.SAM, + model_type="sam_hq_vit_h", + source_repo="lkeab/hq-sam", + artifacts=( + ModelArtifact( + artifact_id="sam_hq_vit_h_checkpoint", + filename="sam_hq_vit_h.pth", + folder_name="sams", + source_url=( + "https://huggingface.co/lkeab/hq-sam/resolve/main/sam_hq_vit_h.pth" + ), + description="SAM-HQ ViT-H checkpoint", + ), + ), + ), + ModelEntry( + entry_id="sam_hq_vit_l", + display_name="sam_hq_vit_l (1.25GB)", + family=ModelFamily.SAM, + model_type="sam_hq_vit_l", + source_repo="lkeab/hq-sam", + artifacts=( + ModelArtifact( + artifact_id="sam_hq_vit_l_checkpoint", + filename="sam_hq_vit_l.pth", + folder_name="sams", + source_url=( + "https://huggingface.co/lkeab/hq-sam/resolve/main/sam_hq_vit_l.pth" + ), + description="SAM-HQ ViT-L checkpoint", + ), + ), + ), + ModelEntry( + entry_id="sam_hq_vit_b", + display_name="sam_hq_vit_b (379MB)", + family=ModelFamily.SAM, + model_type="sam_hq_vit_b", + source_repo="lkeab/hq-sam", + artifacts=( + ModelArtifact( + artifact_id="sam_hq_vit_b_checkpoint", + filename="sam_hq_vit_b.pth", + folder_name="sams", + source_url=( + "https://huggingface.co/lkeab/hq-sam/resolve/main/sam_hq_vit_b.pth" + ), + description="SAM-HQ ViT-B checkpoint", + ), + ), + ), + ModelEntry( + entry_id="mobile_sam", + display_name="mobile_sam (39MB)", + family=ModelFamily.SAM, + model_type="mobile_sam", + source_repo="ChaoningZhang/MobileSAM", + artifacts=( + ModelArtifact( + artifact_id="mobile_sam_checkpoint", + filename="mobile_sam.pt", + folder_name="sams", + source_url=( + "https://github.com/ChaoningZhang/MobileSAM/raw/master/" + "weights/mobile_sam.pt" + ), + description="MobileSAM checkpoint", + ), + ), + ), +) + +GROUNDING_DINO_ENTRIES: tuple[ModelEntry, ...] = ( + ModelEntry( + entry_id="groundingdino_swint_ogc", + display_name="GroundingDINO_SwinT_OGC (694MB)", + family=ModelFamily.GROUNDING_DINO, + model_type="swin_t", + source_repo="ShilongLiu/GroundingDINO", + artifacts=( + ModelArtifact( + artifact_id="groundingdino_swint_ogc_config", + filename="GroundingDINO_SwinT_OGC.cfg.py", + folder_name="grounding-dino", + source_url=( + "https://huggingface.co/ShilongLiu/GroundingDINO/" + "resolve/main/GroundingDINO_SwinT_OGC.cfg.py" + ), + description="GroundingDINO SwinT OGC config", + ), + ModelArtifact( + artifact_id="groundingdino_swint_ogc_checkpoint", + filename="groundingdino_swint_ogc.pth", + folder_name="grounding-dino", + source_url=( + "https://huggingface.co/ShilongLiu/GroundingDINO/" + "resolve/main/groundingdino_swint_ogc.pth" + ), + description="GroundingDINO SwinT OGC checkpoint", + ), + ), + ), + ModelEntry( + entry_id="groundingdino_swinb", + display_name="GroundingDINO_SwinB (938MB)", + family=ModelFamily.GROUNDING_DINO, + model_type="swin_b", + source_repo="ShilongLiu/GroundingDINO", + artifacts=( + ModelArtifact( + artifact_id="groundingdino_swinb_config", + filename="GroundingDINO_SwinB.cfg.py", + folder_name="grounding-dino", + source_url=( + "https://huggingface.co/ShilongLiu/GroundingDINO/" + "resolve/main/GroundingDINO_SwinB.cfg.py" + ), + description="GroundingDINO SwinB config", + ), + ModelArtifact( + artifact_id="groundingdino_swinb_checkpoint", + filename="groundingdino_swinb_cogcoor.pth", + folder_name="grounding-dino", + source_url=( + "https://huggingface.co/ShilongLiu/GroundingDINO/" + "resolve/main/groundingdino_swinb_cogcoor.pth" + ), + description="GroundingDINO SwinB checkpoint", + ), + ), + ), +) + +BERT_ENTRY = ModelEntry( + entry_id="bert_base_uncased", + display_name="BERT base uncased", + family=ModelFamily.TEXT_ENCODER, + model_type="bert", + source_repo="google-bert/bert-base-uncased", + artifacts=( + ModelArtifact( + artifact_id="bert_config", + filename="config.json", + folder_name="text_encoders", + source_url=( + "https://huggingface.co/google-bert/bert-base-uncased/" + "resolve/main/config.json" + ), + description="BERT config", + ), + ModelArtifact( + artifact_id="bert_tokenizer", + filename="tokenizer.json", + folder_name="text_encoders", + source_url=( + "https://huggingface.co/google-bert/bert-base-uncased/" + "resolve/main/tokenizer.json" + ), + description="BERT tokenizer", + ), + ModelArtifact( + artifact_id="bert_tokenizer_config", + filename="tokenizer_config.json", + folder_name="text_encoders", + source_url=( + "https://huggingface.co/google-bert/bert-base-uncased/" + "resolve/main/tokenizer_config.json" + ), + description="BERT tokenizer config", + ), + ModelArtifact( + artifact_id="bert_vocab", + filename="vocab.txt", + folder_name="text_encoders", + source_url=( + "https://huggingface.co/google-bert/bert-base-uncased/" + "resolve/main/vocab.txt" + ), + description="BERT vocabulary", + ), + ModelArtifact( + artifact_id="bert_weights", + filename="model.safetensors", + folder_name="text_encoders", + source_url=( + "https://huggingface.co/google-bert/bert-base-uncased/" + "resolve/main/model.safetensors" + ), + description="BERT weights", + ), + ), +) + +VITMATTE_ENTRIES: tuple[ModelEntry, ...] = ( + ModelEntry( + entry_id="vitmatte-small-composition-1k", + display_name="vitmatte-small-composition-1k", + family=ModelFamily.VITMATTE, + model_type="vitmatte_small", + source_repo="hustvl/vitmatte-small-composition-1k", + artifacts=(), + ), + ModelEntry( + entry_id="vitmatte-base-composition-1k", + display_name="vitmatte-base-composition-1k", + family=ModelFamily.VITMATTE, + model_type="vitmatte_base", + source_repo="hustvl/vitmatte-base-composition-1k", + artifacts=(), + ), +) + +DEFAULT_WD14_TAGGER_MODEL = "wd-eva02-large-tagger-v3" + + +def _wd14_tagger_entry(model_id: str) -> ModelEntry: + """Build a WD14 catalog entry from the canonical SmilingWolf repository.""" + + repo = f"SmilingWolf/{model_id}" + return ModelEntry( + entry_id=model_id, + display_name=model_id, + family=ModelFamily.WD14_TAGGER, + model_type="wd14", + source_repo=repo, + artifacts=( + ModelArtifact( + artifact_id="onnx", + filename=f"{model_id}.onnx", + folder_name="wd14_tagger", + source_url=f"https://huggingface.co/{repo}/resolve/main/model.onnx", + description=f"{model_id} ONNX model", + ), + ModelArtifact( + artifact_id="tags", + filename=f"{model_id}.csv", + folder_name="wd14_tagger", + source_url=( + f"https://huggingface.co/{repo}/resolve/main/selected_tags.csv" + ), + description=f"{model_id} selected tags CSV", + ), + ), + ) + + +WD14_TAGGER_ENTRIES: tuple[ModelEntry, ...] = ( + _wd14_tagger_entry("wd-eva02-large-tagger-v3"), + _wd14_tagger_entry("wd-vit-tagger-v3"), + _wd14_tagger_entry("wd-swinv2-tagger-v3"), + _wd14_tagger_entry("wd-convnext-tagger-v3"), + _wd14_tagger_entry("wd-v1-4-moat-tagger-v2"), + _wd14_tagger_entry("wd-v1-4-convnextv2-tagger-v2"), + _wd14_tagger_entry("wd-v1-4-convnext-tagger-v2"), + _wd14_tagger_entry("wd-v1-4-convnext-tagger"), + _wd14_tagger_entry("wd-v1-4-vit-tagger-v2"), + _wd14_tagger_entry("wd-v1-4-swinv2-tagger-v2"), + _wd14_tagger_entry("wd-v1-4-vit-tagger"), +) + +ANIMA_QWEN_TEXT_ENCODER = AutoModelArtifact( + cache_id="anima_qwen_text_encoder", + filename="qwen_3_06b_base.safetensors", + folder_name="text_encoders", + canonical_subfolder="qwen", + source_url=( + "https://huggingface.co/circlestone-labs/Anima/resolve/main/" + "split_files/text_encoders/qwen_3_06b_base.safetensors" + ), + source_repo="circlestone-labs/Anima", + description="Anima Qwen3 0.6B text encoder", + sha256="cd2a512003e2f9f3cd3c32a9c3573f820bb28c940f73c57b1ddaa983d9223eba", +) + +ANIMA_QWEN_VAE = AutoModelArtifact( + cache_id="anima_qwen_vae", + filename="qwen_image_vae.safetensors", + folder_name="vae", + canonical_subfolder="qwen", + source_url=( + "https://huggingface.co/circlestone-labs/Anima/resolve/main/" + "split_files/vae/qwen_image_vae.safetensors" + ), + source_repo="circlestone-labs/Anima", + description="Anima Qwen Image VAE", + sha256="a70580f0213e67967ee9c95f05bb400e8fb08307e017a924bf3441223e023d1f", +) + +ANIMA_AUTO_ARTIFACTS = (ANIMA_QWEN_TEXT_ENCODER, ANIMA_QWEN_VAE) + + +def sam_choices() -> list[str]: + """Return deterministic SAM dropdown choices.""" + + return [entry.display_name for entry in SAM_ENTRIES] + + +def grounding_dino_choices() -> list[str]: + """Return deterministic GroundingDINO dropdown choices.""" + + return [entry.display_name for entry in GROUNDING_DINO_ENTRIES] + + +def vitmatte_choices() -> list[str]: + """Return deterministic ViTMatte dropdown choices.""" + + return [entry.display_name for entry in VITMATTE_ENTRIES] + + +def wd14_tagger_choices() -> list[str]: + """Return deterministic WD14 tagger dropdown choices.""" + + return [entry.display_name for entry in WD14_TAGGER_ENTRIES] + + +def get_sam_entry(selection: str) -> ModelEntry: + """Return the SAM catalog entry matching an id or display name.""" + + return _get_entry(selection, SAM_ENTRIES, "SAM") + + +def get_grounding_dino_entry(selection: str) -> ModelEntry: + """Return the GroundingDINO catalog entry matching an id or display name.""" + + return _get_entry(selection, GROUNDING_DINO_ENTRIES, "GroundingDINO") + + +def get_vitmatte_entry(selection: str) -> ModelEntry: + """Return the ViTMatte catalog entry matching an id or display name.""" + + return _get_entry(selection, VITMATTE_ENTRIES, "ViTMatte") + + +def get_wd14_tagger_entry(selection: str) -> ModelEntry: + """Return the WD14 tagger catalog entry matching an id or display name.""" + + return _get_entry(selection, WD14_TAGGER_ENTRIES, "WD14 tagger") + + +def _get_entry( + selection: str, + entries: tuple[ModelEntry, ...], + model_label: str, +) -> ModelEntry: + """Return a catalog entry or raise an actionable selection error.""" + + for entry in entries: + if selection in (entry.entry_id, entry.display_name): + return entry + valid = ", ".join(entry.display_name for entry in entries) + raise ValueError( + f"Unknown {model_label} model '{selection}'. Expected one of: {valid}." + ) diff --git a/simple_syrup/runtime/model_choices.py b/simple_syrup/runtime/model_choices.py new file mode 100644 index 0000000..b3fd232 --- /dev/null +++ b/simple_syrup/runtime/model_choices.py @@ -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 + +"""Build settings-aware model dropdown choices for loader nodes.""" + +from __future__ import annotations + +from types import ModuleType +from typing import Protocol + +from .model_catalog import ( + GROUNDING_DINO_ENTRIES, + SAM_ENTRIES, + VITMATTE_ENTRIES, + WD14_TAGGER_ENTRIES, + ModelEntry, + grounding_dino_choices, + sam_choices, + vitmatte_choices, + wd14_tagger_choices, +) +from .model_folders import resolve_model_file +from .settings import SimpleSyrupSettings, SimpleSyrupSettingsRepository +from .vitmatte_loader import ViTMatteLoaderService + +NO_LOCAL_SAM_MODELS = "No local SAM models found" +NO_LOCAL_GROUNDING_DINO_MODELS = "No local GroundingDINO models found" +NO_LOCAL_VITMATTE_MODELS = "No local ViTMatte models found" +NO_LOCAL_WD14_TAGGER_MODELS = "No local WD14 tagger models found" + + +class SettingsProvider(Protocol): + """Settings dependency used by model choice policy.""" + + def load(self) -> SimpleSyrupSettings: + """Return current SimpleSyrup settings.""" + + +class ModelChoiceService: + """Build model loader dropdown choices from settings and local availability.""" + + def __init__( + self, + settings_repository: SettingsProvider | None = None, + folder_paths_module: ModuleType | None = None, + ) -> None: + """Create the choice service with injectable external boundaries.""" + + self._settings_repository = ( + settings_repository or SimpleSyrupSettingsRepository() + ) + self._folder_paths_module = folder_paths_module + self._vitmatte_loader = ViTMatteLoaderService( + folder_paths_module=folder_paths_module + ) + + def sam_choices(self) -> list[str]: + """Return settings-aware SAM dropdown choices.""" + + if self._show_downloadable_models(): + return sam_choices() + + choices = [ + entry.display_name + for entry in SAM_ENTRIES + if self._entry_artifacts_are_local(entry) + ] + return choices or [NO_LOCAL_SAM_MODELS] + + def grounding_dino_choices(self) -> list[str]: + """Return settings-aware GroundingDINO dropdown choices.""" + + if self._show_downloadable_models(): + return grounding_dino_choices() + + choices = [ + entry.display_name + for entry in GROUNDING_DINO_ENTRIES + if self._entry_artifacts_are_local(entry) + ] + return choices or [NO_LOCAL_GROUNDING_DINO_MODELS] + + def vitmatte_choices(self) -> list[str]: + """Return settings-aware ViTMatte dropdown choices.""" + + if self._show_downloadable_models(): + return vitmatte_choices() + + choices = [ + entry.display_name + for entry in VITMATTE_ENTRIES + if self._vitmatte_entry_is_local(entry) + ] + return choices or [NO_LOCAL_VITMATTE_MODELS] + + def wd14_tagger_choices(self) -> list[str]: + """Return settings-aware WD14 tagger dropdown choices.""" + + if self._show_downloadable_models(): + return wd14_tagger_choices() + + choices = [ + entry.display_name + for entry in WD14_TAGGER_ENTRIES + if self._entry_artifacts_are_local(entry) + ] + return choices or [NO_LOCAL_WD14_TAGGER_MODELS] + + def reject_sentinel(self, selection: str) -> None: + """Reject placeholder dropdown selections before loader work begins.""" + + if selection == NO_LOCAL_SAM_MODELS: + raise ValueError( + "No local SAM models are available. Enable 'Show downloadable " + "models in loader dropdowns' in SimpleSyrup settings or install " + "a SAM model in the sams folder." + ) + if selection == NO_LOCAL_GROUNDING_DINO_MODELS: + raise ValueError( + "No local GroundingDINO models are available. Enable 'Show " + "downloadable models in loader dropdowns' in SimpleSyrup " + "settings or install a complete GroundingDINO model in the " + "grounding-dino folder." + ) + if selection == NO_LOCAL_VITMATTE_MODELS: + raise ValueError( + "No local ViTMatte models are available. Enable 'Show " + "downloadable models in loader dropdowns' in SimpleSyrup " + "settings or install a ViTMatte model in the vitmatte folder." + ) + if selection == NO_LOCAL_WD14_TAGGER_MODELS: + raise ValueError( + "No local WD14 tagger models are available. Enable 'Show " + "downloadable models in loader dropdowns' in SimpleSyrup " + "settings or install a WD14 ONNX model and CSV in the " + "wd14_tagger folder." + ) + + def _show_downloadable_models(self) -> bool: + """Return whether known downloadable catalog entries should be visible.""" + + return self._settings_repository.load().show_downloadable_models + + def _entry_artifacts_are_local(self, entry: ModelEntry) -> bool: + """Return whether every catalog artifact exists locally.""" + + return all( + resolve_model_file( + artifact.folder_name, + artifact.filename, + self._folder_paths_module, + ) + is not None + for artifact in entry.artifacts + ) + + def _vitmatte_entry_is_local(self, entry: ModelEntry) -> bool: + """Return whether a valid ViTMatte directory exists locally.""" + + try: + self._vitmatte_loader.resolve_model_directory(entry, auto_download=False) + except FileNotFoundError: + return False + return True + + +def default_choice(choices: list[str], preferred: str) -> str: + """Return the preferred default when visible, otherwise the first choice.""" + + if preferred in choices: + return preferred + return choices[0] diff --git a/simple_syrup/runtime/model_device_manager.py b/simple_syrup/runtime/model_device_manager.py new file mode 100644 index 0000000..7b6d713 --- /dev/null +++ b/simple_syrup/runtime/model_device_manager.py @@ -0,0 +1,277 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy-aware device management for SimpleSyrup-owned torch models.""" + +from __future__ import annotations + +import importlib +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from types import ModuleType +from typing import Any, cast + +import torch + +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + + +@dataclass +class ManagedTorchModel: + """Track one SimpleSyrup-owned model under Comfy's memory manager.""" + + model: object + model_id: str + source: str + patcher: object | None = field(default=None, init=False, repr=False) + load_device: torch.device | None = field(default=None, init=False) + offload_device: torch.device | None = field(default=None, init=False) + + +@dataclass(frozen=True) +class LoadedManagedModel: + """Expose a model loaded for one inference call.""" + + model: object + device: torch.device + policy: str + + +class TorchModelDeviceManager: + """Load SimpleSyrup-owned torch models through Comfy's VRAM policy.""" + + def manage(self, model: object, model_id: str, source: str) -> ManagedTorchModel: + """Return a managed handle for a raw model without moving it to CUDA.""" + + _eval_model(model) + return ManagedTorchModel(model=model, model_id=model_id, source=source) + + @contextmanager + def inference( + self, + managed_model: ManagedTorchModel, + execution_device: str, + ) -> Iterator[LoadedManagedModel]: + """Yield the model loaded on the device selected by `execution_device`.""" + + policy = _validate_execution_device(execution_device) + if policy == "cpu": + device = torch.device("cpu") + _move_model(managed_model.model, device) + _eval_model(managed_model.model) + LOGGER.debug( + "Torch model prepared for CPU inference", + extra={ + "operation": "torch_model_device_manager", + "model": managed_model.model_id, + "policy": policy, + "device": str(device), + "source": managed_model.source, + }, + ) + yield LoadedManagedModel( + model=managed_model.model, + device=device, + policy=policy, + ) + return + + comfy_model_management, comfy_model_patcher = _comfy_modules() + load_device = torch.device(comfy_model_management.get_torch_device()) + if load_device.type == "cpu": + _move_model(managed_model.model, load_device) + _eval_model(managed_model.model) + yield LoadedManagedModel( + model=managed_model.model, + device=load_device, + policy=policy, + ) + return + + offload_device = _resolve_offload_device(comfy_model_management) + if not _supports_comfy_model_patcher(managed_model.model): + LOGGER.debug( + "Torch model uses bounded device movement outside ModelPatcher", + extra={ + "operation": "torch_model_device_manager", + "model": managed_model.model_id, + "policy": policy, + "load_device": str(load_device), + "offload_device": str(offload_device), + "source": managed_model.source, + }, + ) + _move_model(managed_model.model, load_device) + _eval_model(managed_model.model) + try: + yield LoadedManagedModel( + model=managed_model.model, + device=load_device, + policy=policy, + ) + finally: + _move_model(managed_model.model, offload_device) + _soft_empty_cache(comfy_model_management) + return + + patcher = self._patcher_for( + managed_model, + comfy_model_patcher, + load_device, + offload_device, + ) + LOGGER.debug( + "Loading torch model through Comfy model manager", + extra={ + "operation": "torch_model_device_manager", + "model": managed_model.model_id, + "policy": policy, + "load_device": str(load_device), + "offload_device": str(offload_device), + "source": managed_model.source, + }, + ) + comfy_model_management.load_model_gpu(patcher) + _eval_model(managed_model.model) + yield LoadedManagedModel( + model=managed_model.model, + device=load_device, + policy=policy, + ) + + def _patcher_for( + self, + managed_model: ManagedTorchModel, + comfy_model_patcher: ModuleType, + load_device: torch.device, + offload_device: torch.device, + ) -> object: + """Return a reusable `ModelPatcher` for the requested devices.""" + + if ( + managed_model.patcher is not None + and managed_model.load_device == load_device + and managed_model.offload_device == offload_device + ): + return managed_model.patcher + + patcher_class = cast(Any, comfy_model_patcher).ModelPatcher + managed_model.patcher = patcher_class( + managed_model.model, + load_device, + offload_device, + ) + managed_model.load_device = load_device + managed_model.offload_device = offload_device + return managed_model.patcher + + +def resolve_execution_device(execution_device: str) -> torch.device: + """Resolve a public SimpleSyrup execution device policy.""" + + policy = _validate_execution_device(execution_device) + if policy == "cpu": + return torch.device("cpu") + comfy_model_management = importlib.import_module("comfy.model_management") + return torch.device(comfy_model_management.get_torch_device()) + + +@contextmanager +def external_model_inference( + model: object, + execution_device: str, +) -> Iterator[LoadedManagedModel]: + """Move a compatible external raw model for one bounded inference call.""" + + device = resolve_execution_device(execution_device) + original_device = _model_device(model) + _move_model(model, device) + _eval_model(model) + try: + yield LoadedManagedModel(model=model, device=device, policy=execution_device) + finally: + if original_device is not None and original_device != device: + _move_model(model, original_device) + + +def _validate_execution_device(execution_device: str) -> str: + """Return a normalized execution policy or fail clearly.""" + + if execution_device in {"auto", "cpu"}: + return execution_device + raise ValueError("execution_device must be 'auto' or 'cpu'.") + + +def _comfy_modules() -> tuple[ModuleType, ModuleType]: + """Import Comfy model-management modules lazily.""" + + return ( + importlib.import_module("comfy.model_management"), + importlib.import_module("comfy.model_patcher"), + ) + + +def _resolve_offload_device(comfy_model_management: ModuleType) -> torch.device: + """Return Comfy's preferred offload device for auxiliary torch modules.""" + + offload_device = cast(Any, comfy_model_management).text_encoder_offload_device() + return torch.device(offload_device) + + +def _supports_comfy_model_patcher(model: object) -> bool: + """Return whether Comfy's patcher can assign `model.device` safely.""" + + class_device = getattr(type(model), "device", None) + if isinstance(class_device, property) and class_device.fset is None: + return False + return True + + +def _soft_empty_cache(comfy_model_management: ModuleType) -> None: + """Ask Comfy to release cached memory after bounded manual offload.""" + + soft_empty_cache = getattr(comfy_model_management, "soft_empty_cache", None) + if callable(soft_empty_cache): + soft_empty_cache() + + +def _move_model(model: object, device: torch.device) -> None: + """Move a PyTorch-style model when it exposes `.to(...)`.""" + + to_method = getattr(model, "to", None) + if callable(to_method): + to_method(device) + + +def _eval_model(model: object) -> None: + """Set eval mode when the model exposes `.eval()`.""" + + eval_method = getattr(model, "eval", None) + if callable(eval_method): + eval_method() + + +def _model_device(model: object) -> torch.device | None: + """Return the first known torch device for a raw model, if any.""" + + device = getattr(model, "device", None) + if device is not None: + return torch.device(device) + parameters = getattr(model, "parameters", None) + if callable(parameters): + try: + first_parameter = next(iter(parameters())) + except StopIteration: + return None + except TypeError: + return None + if isinstance(first_parameter, torch.Tensor): + return first_parameter.device + parameter_device = getattr(first_parameter, "device", None) + if parameter_device is not None: + return torch.device(parameter_device) + return None diff --git a/simple_syrup/runtime/model_downloads.py b/simple_syrup/runtime/model_downloads.py new file mode 100644 index 0000000..d41f3c0 --- /dev/null +++ b/simple_syrup/runtime/model_downloads.py @@ -0,0 +1,244 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Download known model artifacts with ComfyUI progress reporting.""" + +from __future__ import annotations + +import hashlib +import importlib +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO, Protocol, cast + +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) +CHUNK_SIZE = 1024 * 1024 + + +class ProgressReporter(Protocol): + """Report artifact download progress.""" + + def start(self, label: str, total: int | None) -> None: + """Start reporting one artifact download.""" + + def advance(self, current: int, total: int | None) -> None: + """Report absolute bytes downloaded.""" + + def finish(self) -> None: + """Report completion.""" + + +class _ComfyProgressBar(Protocol): + """Small protocol for the ComfyUI progress bar API used here.""" + + def update_absolute(self, value: int, total: int | None = None) -> None: + """Update absolute progress.""" + + +class NullProgressReporter: + """Ignore progress updates.""" + + def start(self, label: str, total: int | None) -> None: + """Ignore download start.""" + + def advance(self, current: int, total: int | None) -> None: + """Ignore download progress.""" + + def finish(self) -> None: + """Ignore download completion.""" + + +class ComfyProgressReporter: + """Report download progress through ComfyUI's node progress bar.""" + + def __init__(self) -> None: + """Initialize an empty ComfyUI progress reporter.""" + + self._progress_bar: object | None = None + self._total = 1 + + def start(self, label: str, total: int | None) -> None: + """Create a ComfyUI progress bar for one artifact.""" + + comfy_utils = importlib.import_module("comfy.utils") + progress_bar_class = comfy_utils.ProgressBar + self._total = total if total and total > 0 else 1 + self._progress_bar = progress_bar_class(self._total) + self.advance(0, total) + LOGGER.info("download progress started", extra={"label": label, "total": total}) + + def advance(self, current: int, total: int | None) -> None: + """Update the ComfyUI progress bar.""" + + if self._progress_bar is None: + return + if total and total > 0 and total != self._total: + self._total = total + value = ( + current if total and total > 0 else min(current // CHUNK_SIZE, self._total) + ) + progress_bar = cast(_ComfyProgressBar, self._progress_bar) + progress_bar.update_absolute(value, self._total) + + def finish(self) -> None: + """Mark the current ComfyUI progress bar complete.""" + + if self._progress_bar is None: + return + progress_bar = cast(_ComfyProgressBar, self._progress_bar) + progress_bar.update_absolute(self._total, self._total) + + +@dataclass(frozen=True) +class DownloadRequest: + """A trusted catalog download request.""" + + source_url: str + destination_path: Path + expected_folder: Path + description: str + expected_sha256: str | None = None + + +@dataclass(frozen=True) +class DownloadResult: + """The result of resolving or downloading an artifact.""" + + path: Path + bytes_downloaded: int + skipped_existing: bool + + +class ModelDownloader: + """Download trusted model artifacts safely into model folders.""" + + def download( + self, + request: DownloadRequest, + progress: ProgressReporter | None = None, + ) -> DownloadResult: + """Download an artifact unless the final file already exists.""" + + reporter = progress or NullProgressReporter() + destination = request.destination_path + self._validate_destination(destination, request.expected_folder) + + if destination.is_file(): + return DownloadResult( + path=destination, + bytes_downloaded=0, + skipped_existing=True, + ) + + destination.parent.mkdir(parents=True, exist_ok=True) + temporary_path = destination.with_name(f"{destination.name}.part") + + try: + LOGGER.info( + "downloading model artifact", + extra={ + "url": request.source_url, + "destination": str(destination), + "description": request.description, + }, + ) + bytes_downloaded = self._download_to_temporary( + source_url=request.source_url, + temporary_path=temporary_path, + reporter=reporter, + description=request.description, + ) + if request.expected_sha256 is not None: + actual_sha256 = _sha256_file(temporary_path) + if actual_sha256.lower() != request.expected_sha256.lower(): + raise ValueError( + "Downloaded model artifact checksum mismatch for " + f"'{destination}'. Expected {request.expected_sha256}, " + f"got {actual_sha256}." + ) + temporary_path.replace(destination) + reporter.finish() + return DownloadResult( + path=destination, + bytes_downloaded=bytes_downloaded, + skipped_existing=False, + ) + except Exception: + if temporary_path.exists(): + temporary_path.unlink() + LOGGER.exception( + "model artifact download failed", + extra={ + "url": request.source_url, + "destination": str(destination), + "description": request.description, + }, + ) + raise + + def _download_to_temporary( + self, + source_url: str, + temporary_path: Path, + reporter: ProgressReporter, + description: str, + ) -> int: + """Stream a URL to a temporary file.""" + + with urllib.request.urlopen(source_url, timeout=30) as response: + total = _content_length(response) + reporter.start(f"Downloading {description}", total) + bytes_downloaded = 0 + with temporary_path.open("wb") as output: + while True: + chunk = response.read(CHUNK_SIZE) + if not chunk: + break + output.write(chunk) + bytes_downloaded += len(chunk) + reporter.advance(bytes_downloaded, total) + return bytes_downloaded + + def _validate_destination(self, destination: Path, expected_folder: Path) -> None: + """Reject destinations outside the intended model folder.""" + + destination_parent = destination.parent.resolve() + expected_root = expected_folder.resolve() + try: + destination_parent.relative_to(expected_root) + except ValueError as error: + raise ValueError( + f"Download destination '{destination}' is outside '{expected_folder}'." + ) from error + + +def _content_length(response: BinaryIO) -> int | None: + """Return response content length when the server supplies it.""" + + headers = getattr(response, "headers", None) + if headers is None: + return None + value = headers.get("Content-Length") + if value is None: + return None + try: + return int(value) + except ValueError: + return None + + +def _sha256_file(path: Path) -> str: + """Return the SHA256 hex digest for a file.""" + + digest = hashlib.sha256() + with path.open("rb") as file: + while True: + chunk = file.read(CHUNK_SIZE) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() diff --git a/simple_syrup/runtime/model_folders.py b/simple_syrup/runtime/model_folders.py new file mode 100644 index 0000000..2d3fb77 --- /dev/null +++ b/simple_syrup/runtime/model_folders.py @@ -0,0 +1,195 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI model folder registration and bounded discovery helpers.""" + +from __future__ import annotations + +import importlib +from pathlib import Path +from types import ModuleType +from typing import Any + +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) +SUPPORTED_MODEL_EXTENSIONS = frozenset({".pt", ".pth", ".safetensors"}) +WD14_TAGGER_MODEL_EXTENSIONS = frozenset({".onnx", ".csv"}) +MODEL_FOLDER_EXTENSIONS = { + "sams": SUPPORTED_MODEL_EXTENSIONS, + "grounding-dino": SUPPORTED_MODEL_EXTENSIONS, + "vitmatte": SUPPORTED_MODEL_EXTENSIONS, + "wd14_tagger": WD14_TAGGER_MODEL_EXTENSIONS, +} + + +def register_required_model_folders( + folder_paths_module: ModuleType | None = None, +) -> None: + """Register model folders required by grounded SAM if ComfyUI has not.""" + + folder_paths = folder_paths_module or _folder_paths() + models_dir = Path(str(folder_paths.models_dir)) + _register_folder( + folder_paths, + "sams", + models_dir / "sams", + MODEL_FOLDER_EXTENSIONS["sams"], + ) + _register_folder( + folder_paths, + "grounding-dino", + models_dir / "grounding-dino", + MODEL_FOLDER_EXTENSIONS["grounding-dino"], + ) + _register_folder( + folder_paths, + "vitmatte", + models_dir / "vitmatte", + MODEL_FOLDER_EXTENSIONS["vitmatte"], + ) + _register_folder( + folder_paths, + "wd14_tagger", + models_dir / "wd14_tagger", + MODEL_FOLDER_EXTENSIONS["wd14_tagger"], + ) + + +def get_model_folder_paths( + folder_name: str, + folder_paths_module: ModuleType | None = None, +) -> list[Path]: + """Return registered paths plus the conventional fallback for a model folder.""" + + folder_paths = folder_paths_module or _folder_paths() + models_dir = Path(str(folder_paths.models_dir)) + fallback = models_dir / folder_name + registry = getattr(folder_paths, "folder_names_and_paths", {}) + paths: list[Path] = [] + + if folder_name in registry: + registered_paths = registry[folder_name][0] + paths.extend(Path(str(path)) for path in registered_paths) + + if fallback not in paths: + paths.append(fallback) + + return _unique_paths(paths) + + +def get_primary_model_folder( + folder_name: str, + folder_paths_module: ModuleType | None = None, +) -> Path: + """Return the first registered or fallback path for a model folder.""" + + return get_model_folder_paths(folder_name, folder_paths_module)[0] + + +def resolve_model_file( + folder_name: str, + filename: str, + folder_paths_module: ModuleType | None = None, +) -> Path | None: + """Return a model file path when it exists in registered or fallback folders.""" + + safe_filename = Path(filename) + if safe_filename.is_absolute() or ".." in safe_filename.parts: + raise ValueError(f"Model filename '{filename}' is not a safe relative path.") + + for folder in get_model_folder_paths(folder_name, folder_paths_module): + candidate = folder / safe_filename + if candidate.is_file(): + return candidate + return None + + +def expected_model_file( + folder_name: str, + filename: str, + folder_paths_module: ModuleType | None = None, +) -> Path: + """Return the expected destination for a known model artifact.""" + + safe_filename = Path(filename) + if safe_filename.is_absolute() or ".." in safe_filename.parts: + raise ValueError(f"Model filename '{filename}' is not a safe relative path.") + return get_primary_model_folder(folder_name, folder_paths_module) / safe_filename + + +def nonrecursive_model_files( + folder_name: str, + folder_paths_module: ModuleType | None = None, +) -> list[str]: + """Return supported files directly inside a model folder without recursion.""" + + files: set[str] = set() + extensions = _folder_extensions(folder_name, folder_paths_module) + for folder in get_model_folder_paths(folder_name, folder_paths_module): + if not folder.is_dir(): + continue + for path in folder.iterdir(): + if path.is_file() and path.suffix.lower() in extensions: + files.add(path.name) + return sorted(files) + + +def _register_folder( + folder_paths: ModuleType, + folder_name: str, + path: Path, + extensions: frozenset[str], +) -> None: + """Register one model folder if ComfyUI has not already done so.""" + + registry: dict[str, tuple[list[str], set[str]]] = ( + folder_paths.folder_names_and_paths + ) + if folder_name in registry: + return + + add_model_folder_path = folder_paths.add_model_folder_path + add_model_folder_path(folder_name, str(path)) + registry[folder_name] = (registry[folder_name][0], set(extensions)) + LOGGER.info( + "registered model folder", extra={"folder_name": folder_name, "path": str(path)} + ) + + +def _folder_extensions( + folder_name: str, + folder_paths_module: ModuleType | None, +) -> frozenset[str]: + """Return supported extensions for a model folder.""" + + folder_paths = folder_paths_module or _folder_paths() + registry = getattr(folder_paths, "folder_names_and_paths", {}) + if folder_name in registry: + _paths, extensions = registry[folder_name] + return frozenset(extensions) + return MODEL_FOLDER_EXTENSIONS.get(folder_name, SUPPORTED_MODEL_EXTENSIONS) + + +def _unique_paths(paths: list[Path]) -> list[Path]: + """Return paths in order without duplicates.""" + + unique: list[Path] = [] + seen: set[str] = set() + for path in paths: + key = str(path) + if key in seen: + continue + unique.append(path) + seen.add(key) + return unique + + +def _folder_paths() -> ModuleType: + """Import ComfyUI folder paths lazily.""" + + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + return module diff --git a/simple_syrup/runtime/model_instance_cache.py b/simple_syrup/runtime/model_instance_cache.py new file mode 100644 index 0000000..0417bbc --- /dev/null +++ b/simple_syrup/runtime/model_instance_cache.py @@ -0,0 +1,34 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Reuse loaded external model instances by resolved runtime identity.""" + +from __future__ import annotations + +from collections.abc import Callable, MutableMapping +from dataclasses import dataclass, field +from threading import RLock +from typing import Generic, TypeVar + +KeyT = TypeVar("KeyT") +ModelT = TypeVar("ModelT") + + +@dataclass +class ModelInstanceCache(Generic[KeyT, ModelT]): + """Coordinate process-level reuse for SimpleSyrup-owned model instances.""" + + entries: MutableMapping[KeyT, ModelT] = field(default_factory=dict) + _lock: RLock = field(default_factory=RLock, init=False, repr=False) + + def get_or_load(self, key: KeyT, load: Callable[[], ModelT]) -> ModelT: + """Return a cached model instance or load and store it atomically.""" + + with self._lock: + cached = self.entries.get(key) + if cached is not None: + return cached + loaded = load() + self.entries[key] = loaded + return loaded diff --git a/simple_syrup/runtime/model_metadata.py b/simple_syrup/runtime/model_metadata.py new file mode 100644 index 0000000..68173b6 --- /dev/null +++ b/simple_syrup/runtime/model_metadata.py @@ -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 + +"""Request model catalog and local installation metadata.""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import ModuleType + +from .grounding_dino_loader import TEXT_ENCODER_CHOICES +from .model_catalog import ( + VITMATTE_ENTRIES, + ModelEntry, + get_grounding_dino_entry, + get_sam_entry, +) +from .model_folders import ( + expected_model_file, + get_primary_model_folder, + register_required_model_folders, + resolve_model_file, +) +from .vitmatte_loader import is_valid_vitmatte_directory + + +class GroundedSAMModelMetadata: + """Describe known SAM, GroundingDINO, and text encoder assets.""" + + def __init__(self, folder_paths_module: ModuleType | None = None) -> None: + """Create a metadata provider with injectable folder paths.""" + + self._folder_paths_module = folder_paths_module + + def describe_selection(self, sam_model: str, grounding_dino_model: str) -> str: + """Return JSON metadata for selected model catalog entries.""" + + register_required_model_folders(self._folder_paths_module) + sam = get_sam_entry(sam_model) + dino = get_grounding_dino_entry(grounding_dino_model) + payload = { + "sam": self._entry_metadata(sam), + "grounding_dino": self._entry_metadata(dino), + "text_encoder_choices": list(TEXT_ENCODER_CHOICES), + "text_encoder_paths": { + "layerstyle": str(self._models_dir() / "bert-base-uncased"), + "text_encoders_bert": str( + expected_model_file( + "text_encoders", + "bert", + self._folder_paths_module, + ) + ), + }, + "vitmatte": [self._vitmatte_metadata(entry) for entry in VITMATTE_ENTRIES], + } + return json.dumps(payload, indent=2, sort_keys=True) + + def _entry_metadata(self, entry: ModelEntry) -> dict[str, object]: + """Return JSON-serializable metadata for one catalog entry.""" + + artifacts: list[dict[str, object]] = [] + for artifact in entry.artifacts: + local_path = resolve_model_file( + artifact.folder_name, + artifact.filename, + self._folder_paths_module, + ) + expected = expected_model_file( + artifact.folder_name, + artifact.filename, + self._folder_paths_module, + ) + artifacts.append( + { + "artifact_id": artifact.artifact_id, + "filename": artifact.filename, + "source_url": artifact.source_url, + "expected_path": str(expected), + "local_path": str(local_path) if local_path else None, + "installed": local_path is not None, + } + ) + return { + "id": entry.entry_id, + "display_name": entry.display_name, + "model_type": entry.model_type, + "source_repo": entry.source_repo, + "auto_download_allowed": entry.auto_download_allowed, + "artifacts": artifacts, + } + + def _vitmatte_metadata(self, entry: ModelEntry) -> dict[str, object]: + """Return metadata for one ViTMatte model directory.""" + + canonical = ( + get_primary_model_folder( + "vitmatte", + self._folder_paths_module, + ) + / entry.entry_id + ) + layerstyle = self._vitmatte_layerstyle_path(entry) + candidates = [canonical] + if layerstyle is not None and layerstyle not in candidates: + candidates.append(layerstyle) + installed_path = next( + ( + candidate + for candidate in candidates + if is_valid_vitmatte_directory(candidate) + ), + None, + ) + return { + "id": entry.entry_id, + "display_name": entry.display_name, + "model_type": entry.model_type, + "source_repo": entry.source_repo, + "canonical_path": str(canonical), + "layerstyle_compatible_path": str(layerstyle) if layerstyle else None, + "installed": installed_path is not None, + "local_path": str(installed_path) if installed_path else None, + } + + def _vitmatte_layerstyle_path(self, entry: ModelEntry) -> Path | None: + """Return LayerStyle's compatible ViTMatte path for one entry.""" + + if entry.entry_id == "vitmatte-small-composition-1k": + return self._models_dir() / "vitmatte" + if entry.entry_id == "vitmatte-base-composition-1k": + return self._models_dir() / "vitmatte-base-composition-1k" + return None + + def _models_dir(self) -> Path: + """Return ComfyUI's model root directory.""" + + import importlib + + folder_paths = self._folder_paths_module or importlib.import_module( + "folder_paths" + ) + return Path(str(folder_paths.models_dir)) diff --git a/simple_syrup/runtime/multidiffusion_sampling.py b/simple_syrup/runtime/multidiffusion_sampling.py new file mode 100644 index 0000000..9956722 --- /dev/null +++ b/simple_syrup/runtime/multidiffusion_sampling.py @@ -0,0 +1,317 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Portions of this file incorporate behavior derived from +# multidiffusion-upscaler-for-automatic1111. See third_party/manifest.toml and +# third_party/NOTICE.md. + +"""ComfyUI runtime adapter for MultiDiffusion tiled sampling.""" + +from __future__ import annotations + +from collections.abc import Sequence +from importlib import import_module +from types import ModuleType +from typing import Any, cast + +import torch + +from ..domain.tiled_diffusion import ( + LatentTile, + TiledDiffusionPlan, + build_tiled_diffusion_plan, +) +from ..shared.logging import get_logger +from . import sampling_samplers, sampling_schedulers +from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback +from .tiled_sampling import ( + ApplyModel, + Latent, + ModelFunctionWrapper, + make_tiled_model_args, + new_spatial_weight_buffer, + reject_unsupported_conditioning, + spatial_tile_slicer, + validate_latent_samples, + validate_sampling_controls, + validate_tensor_shape, +) + +LOGGER = get_logger(__name__) +SAMPLER_LABEL = "MultiDiffusion" +UNIPC_SAMPLERS = frozenset({"uni_pc", "uni_pc_bh2"}) + + +def sample_multidiffusion( + *, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None = None, +) -> Latent: + """Sample a latent with a cloned model patched for MultiDiffusion.""" + + validate_sampling_controls( + steps=steps, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_batch_size=latent_tile_batch_size, + ) + _reject_unipc_sampler(sampler_name) + reject_unsupported_conditioning(positive, sampler_label=SAMPLER_LABEL) + reject_unsupported_conditioning(negative, sampler_label=SAMPLER_LABEL) + + sampler = sampling_samplers.resolve_sampler(sampler_name) + sigmas = sampling_schedulers.calculate_sigmas( + model=model, + scheduler_name=scheduler, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + ).to(model.load_device) + + latent_samples = validate_latent_samples( + latent_image, + sampler_label=SAMPLER_LABEL, + ) + comfy_sample = _comfy_sample() + comfy_utils = _comfy_utils() + + latent_samples = comfy_sample.fix_empty_latent_channels( + model, + latent_samples, + latent_image.get("downscale_ratio_spacial", None), + ) + validate_tensor_shape(latent_samples, sampler_label=SAMPLER_LABEL) + latent_height = int(latent_samples.shape[-2]) + latent_width = int(latent_samples.shape[-1]) + sampling_model, plan = clone_model_with_multidiffusion( + model, + latent_width=latent_width, + latent_height=latent_height, + tile_width=latent_tile_width, + tile_height=latent_tile_height, + overlap=latent_tile_overlap, + tile_batch_size=latent_tile_batch_size, + ) + + batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None + noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds) + noise_mask = latent_image.get("noise_mask", None) + callback = _sampling_callback(sampling_model, steps, preview_context) + samples = comfy_sample.sample_custom( + sampling_model, + noise, + cfg, + sampler, + sigmas, + positive, + negative, + latent_samples, + noise_mask=noise_mask, + callback=callback, + disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED, + seed=seed, + ) + + LOGGER.info( + "KSampler MultiDiffusion pass completed", + extra={ + "operation": "ksampler_multidiffusion", + "sampler": sampler_name, + "scheduler": scheduler, + "steps": steps, + "denoise": denoise, + "latent_width": latent_width, + "latent_height": latent_height, + "latent_ndim": latent_samples.ndim, + "tile_width": plan.tile_width, + "tile_height": plan.tile_height, + "overlap": plan.overlap, + "tile_count": len(plan.tiles), + "requested_tile_batch_size": plan.requested_tile_batch_size, + "tile_batch_size": plan.tile_batch_size, + }, + ) + + output = latent_image.copy() + output.pop("downscale_ratio_spacial", None) + output["samples"] = samples + return output + + +def clone_model_with_multidiffusion( + model: Any, + *, + latent_width: int, + latent_height: int, + tile_width: int, + tile_height: int, + overlap: int, + tile_batch_size: int, +) -> tuple[Any, TiledDiffusionPlan]: + """Return a model clone patched with a pre-CFG MultiDiffusion wrapper.""" + + 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, + ) + cloned_model = model.clone() + old_wrapper = cloned_model.model_options.get("model_function_wrapper") + if old_wrapper is not None and not callable(old_wrapper): + raise ValueError("Existing model_function_wrapper is not callable.") + + wrapper = MultiDiffusionModelWrapper( + plan=plan, + existing_wrapper=cast(ModelFunctionWrapper | None, old_wrapper), + ) + cloned_model.set_model_unet_function_wrapper(wrapper) + return cloned_model, plan + + +class MultiDiffusionModelWrapper: + """Average tiled model predictions before ComfyUI CFG combines them.""" + + def __init__( + self, + *, + plan: TiledDiffusionPlan, + existing_wrapper: ModelFunctionWrapper | None, + ) -> None: + """Create the model wrapper for one latent sampling shape.""" + + self._plan = plan + self._existing_wrapper = existing_wrapper + + def __call__( + self, + apply_model: ApplyModel, + args: dict[str, Any], + ) -> torch.Tensor: + """Run the original model on latent tiles and average overlapping output.""" + + x = args["input"] + if not isinstance(x, torch.Tensor): + raise ValueError("MultiDiffusion model input must be a tensor.") + validate_tensor_shape(x, sampler_label=SAMPLER_LABEL) + if x.shape[-2:] != (self._plan.latent_height, self._plan.latent_width): + return self._call_original(apply_model, args) + if len(self._plan.tiles) <= 1: + return self._call_original(apply_model, args) + + timestep = args["timestep"] + if not isinstance(timestep, torch.Tensor): + raise ValueError("MultiDiffusion timestep must be a tensor.") + conditioning = args.get("c", {}) + if not isinstance(conditioning, dict): + raise ValueError("MultiDiffusion conditioning must be a dict.") + if conditioning.get("control") is not None: + raise ValueError( + "MultiDiffusion does not support regional conditioning or " + "ControlNet in the first implementation." + ) + + output_buffer = torch.zeros_like(x) + weight_buffer = new_spatial_weight_buffer(x, self._plan) + input_batch_size = int(x.shape[0]) + + for batch in self._plan.batches: + tiled_args = self._make_tiled_args( + args=args, + tiles=batch, + input_batch_size=input_batch_size, + ) + tile_output = self._call_original(apply_model, tiled_args) + for index, tile in enumerate(batch): + tile_slice = spatial_tile_slicer(tile, x.ndim) + start = index * input_batch_size + end = start + input_batch_size + output_buffer[tile_slice] += tile_output[start:end] + weight_buffer[tile_slice] += 1.0 + + return output_buffer / weight_buffer.to(dtype=output_buffer.dtype) + + def _call_original( + self, + apply_model: ApplyModel, + args: dict[str, Any], + ) -> torch.Tensor: + """Call the preserved model wrapper or raw apply_model.""" + + if self._existing_wrapper is not None: + return self._existing_wrapper(apply_model, args) + conditioning = args.get("c", {}) + if not isinstance(conditioning, dict): + raise ValueError("MultiDiffusion conditioning must be a dict.") + return apply_model(args["input"], args["timestep"], **conditioning) + + def _make_tiled_args( + self, + *, + args: dict[str, Any], + tiles: Sequence[LatentTile], + input_batch_size: int, + ) -> dict[str, Any]: + """Create apply-model args for one tile batch.""" + + return make_tiled_model_args( + args=args, + tiles=tiles, + input_batch_size=input_batch_size, + latent_height=self._plan.latent_height, + latent_width=self._plan.latent_width, + ) + + +def _reject_unipc_sampler(sampler_name: str) -> None: + """Reject UniPC samplers because MultiDiffusion is incompatible with them.""" + + if sampler_name in UNIPC_SAMPLERS: + raise ValueError("MultiDiffusion is not compatible with UniPC samplers.") + + +def _sampling_callback( + model: Any, + steps: int, + preview_context: DetailPreviewContext | None, +) -> Any: + """Return a generic or detailer-specific sampling preview callback.""" + + if preview_context is None: + return _latent_preview().prepare_callback(model, steps) + return prepare_detail_preview_callback(model, steps, preview_context) + + +def _comfy_sample() -> ModuleType: + """Import ComfyUI sample helpers lazily.""" + + return import_module("comfy.sample") + + +def _comfy_utils() -> ModuleType: + """Import ComfyUI utility state lazily.""" + + return import_module("comfy.utils") + + +def _latent_preview() -> ModuleType: + """Import ComfyUI preview helpers lazily.""" + + return import_module("latent_preview") diff --git a/simple_syrup/runtime/progress.py b/simple_syrup/runtime/progress.py new file mode 100644 index 0000000..fc39a74 --- /dev/null +++ b/simple_syrup/runtime/progress.py @@ -0,0 +1,33 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Small progress reporting boundary for ComfyUI node work.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Protocol, cast + + +class ProgressReporter(Protocol): + """Progress sink compatible with ComfyUI progress bars.""" + + def update(self, value: int) -> None: + """Advance progress by value.""" + + +class NullProgressReporter: + """Ignore progress updates when no UI progress sink is available.""" + + def update(self, value: int) -> None: + """Ignore progress updates.""" + + +def create_comfy_progress(total: int) -> ProgressReporter: + """Return a ComfyUI progress bar for total units of work.""" + + if total <= 0: + return NullProgressReporter() + comfy_utils = import_module("comfy.utils") + return cast(ProgressReporter, comfy_utils.ProgressBar(total)) diff --git a/simple_syrup/runtime/prompt_control_availability.py b/simple_syrup/runtime/prompt_control_availability.py new file mode 100644 index 0000000..23167e7 --- /dev/null +++ b/simple_syrup/runtime/prompt_control_availability.py @@ -0,0 +1,67 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Detect Prompt Control installation without importing its node modules.""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib.util import find_spec +from pathlib import Path + + +@dataclass(frozen=True) +class PromptControlAvailability: + """Describe whether Prompt Control can be resolved.""" + + is_available: bool + root_path: Path | None + + +def find_prompt_control_install( + custom_nodes_root: Path | None = None, +) -> PromptControlAvailability: + """Find Prompt Control code without importing Prompt Control nodes.""" + + spec_availability = _find_prompt_control_from_python_path() + if spec_availability.is_available: + return spec_availability + + root = custom_nodes_root or default_custom_nodes_root() + package_path = root / "comfyui-prompt-control" / "prompt_control" + if (package_path / "nodes_lazy.py").is_file(): + return PromptControlAvailability( + is_available=True, + root_path=package_path.parent, + ) + return PromptControlAvailability(is_available=False, root_path=None) + + +def prompt_control_is_available() -> bool: + """Return whether Prompt Control can be advertised safely.""" + + return find_prompt_control_install().is_available + + +def default_custom_nodes_root() -> Path: + """Return the ComfyUI custom_nodes directory for this checkout.""" + + return Path(__file__).resolve().parents[2].parent + + +def _find_prompt_control_from_python_path() -> PromptControlAvailability: + """Inspect Python import specs without importing Prompt Control submodules.""" + + spec = find_spec("prompt_control") + if spec is None or spec.submodule_search_locations is None: + return PromptControlAvailability(is_available=False, root_path=None) + + for location in spec.submodule_search_locations: + package_path = Path(location) + if (package_path / "nodes_lazy.py").is_file(): + return PromptControlAvailability( + is_available=True, + root_path=package_path.parent, + ) + return PromptControlAvailability(is_available=False, root_path=None) diff --git a/simple_syrup/runtime/prompt_control_batch_graph.py b/simple_syrup/runtime/prompt_control_batch_graph.py new file mode 100644 index 0000000..5179a05 --- /dev/null +++ b/simple_syrup/runtime/prompt_control_batch_graph.py @@ -0,0 +1,133 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Runtime graph expansion for Prompt Control conditioning batches.""" + +from __future__ import annotations + +import sys +from importlib import import_module +from typing import Any, cast + +from ..domain.conditioning_batch import split_prompt_batch +from .prompt_control_availability import find_prompt_control_install + +PROMPT_CONTROL_MISSING_MESSAGE = ( + "Encode Prompt Batch w/ Prompt Control requires comfyui-prompt-control. " + "Install Prompt Control or use Encode Prompt Batch." +) + + +class PromptControlBatchGraphBuilder: + """Build lazy Prompt Control graphs that return conditioning batches.""" + + def build( + self, + clip: Any, + positive_prompt: str, + negative_prompt: str, + separator: str, + ) -> Any: + """Return an io.NodeOutput with positive and negative batch links.""" + + io, graph_utils, lazy_node = self._prompt_control_dependencies() + positive_chunks = split_prompt_batch(positive_prompt, separator) + negative_chunks = split_prompt_batch(negative_prompt, separator) + + expand: dict[str, dict[str, Any]] = {} + positive_output, positive_expand = self._encode_chunks( + chunks=positive_chunks, + clip=clip, + graph_utils=graph_utils, + lazy_node=lazy_node, + ) + negative_output, negative_expand = self._encode_chunks( + chunks=negative_chunks, + clip=clip, + graph_utils=graph_utils, + lazy_node=lazy_node, + ) + expand.update(positive_expand) + expand.update(negative_expand) + return io.NodeOutput(positive_output, negative_output, expand=expand) + + def _encode_chunks( + self, + chunks: tuple[str, ...], + clip: Any, + graph_utils: Any, + lazy_node: Any, + ) -> tuple[list[Any], dict[str, dict[str, Any]]]: + """Encode chunks with Prompt Control and pack the resulting links.""" + + expand: dict[str, dict[str, Any]] = {} + conditioning_outputs: list[Any] = [] + for chunk in chunks: + node_output = lazy_node.execute( + clip=clip, + text=chunk, + tags="", + start=0.0, + end=1.0, + num_steps=0, + ) + node_expand = cast(dict[str, dict[str, Any]], node_output.expand or {}) + overlap = set(expand).intersection(node_expand) + if overlap: + overlapping_ids = ", ".join(sorted(overlap)) + raise ValueError( + "Prompt Control generated duplicate graph node ids: " + f"{overlapping_ids}." + ) + expand.update(node_expand) + conditioning_outputs.append(node_output.args[0]) + + pack_graph = graph_utils.GraphBuilder() + current = pack_graph.node( + "SimpleSyrup.ConditioningBatchStart", + conditioning=conditioning_outputs[0], + ) + for conditioning in conditioning_outputs[1:]: + current = pack_graph.node( + "SimpleSyrup.ConditioningBatchAppend", + batch=current.out(0), + conditioning=conditioning, + ) + pack_expand = cast(dict[str, dict[str, Any]], pack_graph.finalize()) + overlap = set(expand).intersection(pack_expand) + if overlap: + overlapping_ids = ", ".join(sorted(overlap)) + raise ValueError( + f"SimpleSyrup generated duplicate graph node ids: {overlapping_ids}." + ) + expand.update(pack_expand) + return current.out(0), expand + + def _prompt_control_dependencies(self) -> tuple[Any, Any, Any]: + """Import Prompt Control and Comfy v3 dependencies on demand.""" + + try: + io = import_module("comfy_api.latest.io") + except ModuleNotFoundError: + comfy_api = import_module("comfy_api.latest") + io = comfy_api.io + try: + graph_utils = import_module("comfy_execution.graph_utils") + lazy_nodes = self._import_prompt_control_lazy_nodes() + except ModuleNotFoundError as exc: + raise RuntimeError(PROMPT_CONTROL_MISSING_MESSAGE) from exc + return io, graph_utils, lazy_nodes.PCLazyTextEncodeAdvanced + + def _import_prompt_control_lazy_nodes(self) -> Any: + """Import Prompt Control lazy nodes from normal or sibling extension paths.""" + + try: + return import_module("prompt_control.nodes_lazy") + except ModuleNotFoundError: + availability = find_prompt_control_install() + if availability.root_path is not None: + root_path = str(availability.root_path) + if root_path not in sys.path: + sys.path.insert(0, root_path) + return import_module("prompt_control.nodes_lazy") diff --git a/simple_syrup/runtime/regional_multidiffusion_sampling.py b/simple_syrup/runtime/regional_multidiffusion_sampling.py new file mode 100644 index 0000000..83f5325 --- /dev/null +++ b/simple_syrup/runtime/regional_multidiffusion_sampling.py @@ -0,0 +1,555 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Portions of this file incorporate behavior derived from +# multidiffusion-upscaler-for-automatic1111. See third_party/manifest.toml and +# third_party/NOTICE.md. + +"""ComfyUI runtime adapter for SEGS regional MultiDiffusion sampling.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from importlib import import_module +from types import ModuleType +from typing import Any, TypeAlias, cast + +import torch + +from ..domain.regional_detailing import LatentRegion +from ..shared.logging import get_logger +from . import sampling_samplers, sampling_schedulers +from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback +from .tiled_sampling import ( + Latent, + reject_unsupported_conditioning, + validate_latent_samples, + validate_tensor_shape, +) + +LOGGER = get_logger(__name__) +SAMPLER_LABEL = "Regional MultiDiffusion" +UNIPC_SAMPLERS = frozenset({"uni_pc", "uni_pc_bh2"}) +CalcCondBatchFunction: TypeAlias = Callable[[dict[str, Any]], list[torch.Tensor]] + + +@dataclass(frozen=True) +class RegionalMultiDiffusionSummary: + """Describe regional work installed on a cloned model.""" + + latent_width: int + latent_height: int + latent_ndim: int + region_count: int + max_region_width: int + max_region_height: int + + +def sample_regional_multidiffusion( + *, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + regions: tuple[LatentRegion, ...], + denoise: float, + global_prompt_weight: float, + preview_context: DetailPreviewContext | None = None, +) -> Latent: + """Sample a latent with regional MultiDiffusion prompt blending.""" + + _validate_sampling_controls( + steps=steps, + denoise=denoise, + global_prompt_weight=global_prompt_weight, + ) + _reject_unipc_sampler(sampler_name) + if not regions: + raise ValueError("Regional MultiDiffusion requires at least one region.") + reject_unsupported_conditioning(positive, sampler_label=SAMPLER_LABEL) + reject_unsupported_conditioning(negative, sampler_label=SAMPLER_LABEL) + for region in regions: + reject_unsupported_conditioning(region.positive, sampler_label=SAMPLER_LABEL) + + sampler = sampling_samplers.resolve_sampler(sampler_name) + sigmas = sampling_schedulers.calculate_sigmas( + model=model, + scheduler_name=scheduler, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + ).to(model.load_device) + + latent_samples = validate_latent_samples( + latent_image, + sampler_label=SAMPLER_LABEL, + ) + comfy_sample = _comfy_sample() + comfy_utils = _comfy_utils() + + latent_samples = comfy_sample.fix_empty_latent_channels( + model, + latent_samples, + latent_image.get("downscale_ratio_spacial", None), + ) + validate_tensor_shape(latent_samples, sampler_label=SAMPLER_LABEL) + latent_height = int(latent_samples.shape[-2]) + latent_width = int(latent_samples.shape[-1]) + sampling_model, summary = clone_model_with_regional_multidiffusion( + model, + latent_width=latent_width, + latent_height=latent_height, + latent_ndim=latent_samples.ndim, + regions=regions, + global_prompt_weight=global_prompt_weight, + ) + + batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None + noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds) + noise_mask = latent_image.get("noise_mask", None) + callback = _sampling_callback(sampling_model, steps, preview_context) + samples = comfy_sample.sample_custom( + sampling_model, + noise, + cfg, + sampler, + sigmas, + positive, + negative, + latent_samples, + noise_mask=noise_mask, + callback=callback, + disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED, + seed=seed, + ) + + LOGGER.info( + "Regional MultiDiffusion pass completed", + extra={ + "operation": "regional_multidiffusion", + "sampler": sampler_name, + "scheduler": scheduler, + "steps": steps, + "denoise": denoise, + "latent_width": summary.latent_width, + "latent_height": summary.latent_height, + "latent_ndim": summary.latent_ndim, + "region_count": summary.region_count, + "max_region_width": summary.max_region_width, + "max_region_height": summary.max_region_height, + "region_positive_count": len(regions), + "global_prompt_weight": global_prompt_weight, + }, + ) + + output = latent_image.copy() + output.pop("downscale_ratio_spacial", None) + output["samples"] = samples + return output + + +def clone_model_with_regional_multidiffusion( + model: Any, + *, + latent_width: int, + latent_height: int, + latent_ndim: int, + regions: tuple[LatentRegion, ...], + global_prompt_weight: float = 0.0, +) -> tuple[Any, RegionalMultiDiffusionSummary]: + """Return a model clone patched with regional calc-cond-batch blending.""" + + _validate_sampling_controls( + steps=1, + denoise=1.0, + global_prompt_weight=global_prompt_weight, + ) + _validate_regions( + latent_width=latent_width, + latent_height=latent_height, + regions=regions, + ) + cloned_model = model.clone() + old_wrapper = cloned_model.model_options.get("sampler_calc_cond_batch_function") + if old_wrapper is not None and not callable(old_wrapper): + raise ValueError("Existing sampler_calc_cond_batch_function is not callable.") + + wrapper = RegionalMultiDiffusionCalcCondBatch( + latent_width=latent_width, + latent_height=latent_height, + regions=regions, + existing_calc_cond_batch=cast(CalcCondBatchFunction | None, old_wrapper), + global_prompt_weight=global_prompt_weight, + ) + cloned_model.set_model_sampler_calc_cond_batch_function(wrapper) + summary = RegionalMultiDiffusionSummary( + latent_width=latent_width, + latent_height=latent_height, + latent_ndim=latent_ndim, + region_count=len(regions), + max_region_width=max( + (region.latent_box.width for region in regions), default=0 + ), + max_region_height=max( + (region.latent_box.height for region in regions), + default=0, + ), + ) + return cloned_model, summary + + +class RegionalMultiDiffusionCalcCondBatch: + """Blend regional condition predictions before CFG is applied.""" + + def __init__( + self, + *, + latent_width: int, + latent_height: int, + regions: tuple[LatentRegion, ...], + existing_calc_cond_batch: CalcCondBatchFunction | None, + global_prompt_weight: float, + ) -> None: + """Create a calc-cond-batch wrapper for one latent sampling shape.""" + + self._latent_width = latent_width + self._latent_height = latent_height + self._regions = regions + self._existing_calc_cond_batch = existing_calc_cond_batch + self._global_prompt_weight = global_prompt_weight + + def __call__(self, args: dict[str, Any]) -> list[torch.Tensor]: + """Return fallback predictions blended with regional predictions.""" + + x = args["input"] + if not isinstance(x, torch.Tensor): + raise ValueError("Regional MultiDiffusion model input must be a tensor.") + validate_tensor_shape(x, sampler_label=SAMPLER_LABEL) + if x.shape[-2:] != (self._latent_height, self._latent_width): + return self._call_original(args) + if not self._regions: + return self._call_original(args) + + timestep = args["sigma"] + if not isinstance(timestep, torch.Tensor): + raise ValueError("Regional MultiDiffusion sigma must be a tensor.") + conds = args["conds"] + if not isinstance(conds, list) or not conds: + raise ValueError("Regional MultiDiffusion conds must be a non-empty list.") + + fallback = self._call_original(args) + regional_buffers = [torch.zeros_like(output) for output in fallback] + regional_weights = [_new_spatial_weight(x) for _output in fallback] + input_batch_size = int(x.shape[0]) + + for region in self._regions: + region_slice = _region_slicer(region, x.ndim) + region_x = x[region_slice] + region_conds = [ + _prepare_region_conditioning( + region.positive, + args=args, + x=region_x, + ), + *conds[1:], + ] + region_args = args.copy() + region_args["conds"] = region_conds + region_args["input"] = region_x + region_args["sigma"] = timestep + region_outputs = self._call_original(region_args) + self._accumulate_region_outputs( + outputs=region_outputs, + buffers=regional_buffers, + weights=regional_weights, + region=region, + input_batch_size=input_batch_size, + ) + + return [ + _blend_prediction( + fallback_output, + region_output, + region_weight, + global_prompt_weight=self._global_prompt_weight, + ) + for fallback_output, region_output, region_weight in zip( + fallback, + regional_buffers, + regional_weights, + strict=True, + ) + ] + + def _call_original(self, args: dict[str, Any]) -> list[torch.Tensor]: + """Call the previous calc-cond-batch hook or ComfyUI default.""" + + clean_args = args.copy() + clean_options = _clean_model_options( + cast(dict[str, Any], clean_args["model_options"]), + self._existing_calc_cond_batch, + ) + clean_args["model_options"] = clean_options + if self._existing_calc_cond_batch is not None: + return self._existing_calc_cond_batch(clean_args) + comfy_samplers = _comfy_samplers() + return cast( + list[torch.Tensor], + comfy_samplers.calc_cond_batch( + clean_args["model"], + clean_args["conds"], + clean_args["input"], + clean_args["sigma"], + clean_options, + ), + ) + + def _accumulate_region_outputs( + self, + *, + outputs: list[torch.Tensor], + buffers: list[torch.Tensor], + weights: list[torch.Tensor], + region: LatentRegion, + input_batch_size: int, + ) -> None: + """Accumulate one region prediction into full-latent buffers.""" + + for output_index, output in enumerate(outputs[: len(buffers)]): + region_slice = _region_slicer(region, output.ndim) + box = region.latent_box + mask_slice = ( + region.latent_mask[ + box.y : box.y + box.height, + box.x : box.x + box.width, + ] + .reshape((1,) * (output.ndim - 2) + (box.height, box.width)) + .to(device=output.device, dtype=torch.float32) + ) + buffers[output_index][region_slice] += output[ + :input_batch_size + ] * mask_slice.to(dtype=output.dtype) + weights[output_index][region_slice] += mask_slice + + +def _validate_regions( + *, + latent_width: int, + latent_height: int, + regions: tuple[LatentRegion, ...], +) -> None: + """Reject regions incompatible with the current latent shape.""" + + for region in regions: + _validate_region(region, latent_width=latent_width, latent_height=latent_height) + + +def _validate_region( + region: LatentRegion, + *, + latent_width: int, + latent_height: int, +) -> None: + """Reject regions incompatible with the current latent shape.""" + + box = region.latent_box + if region.latent_mask.shape != (latent_height, latent_width): + raise ValueError( + f"Regional MultiDiffusion region {region.index} ('{region.label}') " + "latent_mask must match the full latent height and width." + ) + if box.x < 0 or box.y < 0 or box.width < 1 or box.height < 1: + raise ValueError( + f"Regional MultiDiffusion region {region.index} ('{region.label}') " + "has an invalid latent box." + ) + if box.x + box.width > latent_width or box.y + box.height > latent_height: + raise ValueError( + f"Regional MultiDiffusion region {region.index} ('{region.label}') " + "latent box must fit inside the latent." + ) + + +def _region_slicer(region: LatentRegion, tensor_ndim: int) -> tuple[slice, ...]: + """Return a slicer that crops a tensor to a latent region box.""" + + box = region.latent_box + return ( + (slice(None),) * (tensor_ndim - 2) + + (slice(box.y, box.y + box.height),) + + (slice(box.x, box.x + box.width),) + ) + + +def _new_spatial_weight(x: torch.Tensor) -> torch.Tensor: + """Create a full-latent spatial weight buffer.""" + + return torch.zeros( + (1,) * (x.ndim - 2) + (int(x.shape[-2]), int(x.shape[-1])), + device=x.device, + dtype=torch.float32, + ) + + +def _blend_prediction( + fallback: torch.Tensor, + regional: torch.Tensor, + weight: torch.Tensor, + *, + global_prompt_weight: float, +) -> torch.Tensor: + """Blend normalized regional predictions with global fallback predictions.""" + + has_region = weight > 0 + normalized = torch.where( + has_region, + regional / torch.clamp(weight, min=1.0e-37).to(dtype=regional.dtype), + regional, + ) + coverage = torch.clamp(weight, 0.0, 1.0).to(dtype=fallback.dtype) + regional_alpha = coverage * (1.0 - global_prompt_weight) + blended = fallback * (1.0 - regional_alpha) + normalized * regional_alpha + return torch.where(has_region, blended, fallback) + + +def _validate_sampling_controls( + *, + steps: int, + denoise: float, + global_prompt_weight: float, +) -> None: + """Reject invalid sampler controls before ComfyUI runtime calls.""" + + if steps < 1: + raise ValueError("steps must be at least 1.") + if not 0.0 <= denoise <= 1.0: + raise ValueError("denoise must be between 0 and 1.") + _validate_global_prompt_weight(global_prompt_weight) + + +def _validate_global_prompt_weight(global_prompt_weight: float) -> None: + """Reject global prompt weights outside the normalized blend range.""" + + if not 0.0 <= global_prompt_weight <= 1.0: + raise ValueError("global_prompt_weight must be between 0.0 and 1.0.") + + +def _prepare_region_conditioning( + conditioning: object, + *, + args: dict[str, Any], + x: torch.Tensor, +) -> list[dict[str, Any]]: + """Convert raw Comfy CONDITIONING into sampler-ready condition dictionaries.""" + + if _is_processed_conditioning(conditioning): + return cast(list[dict[str, Any]], conditioning) + if not isinstance(conditioning, list): + raise TypeError("Regional MultiDiffusion region_positive must be CONDITIONING.") + + sampler_helpers = _comfy_sampler_helpers() + comfy_samplers = _comfy_samplers() + model = args["model"] + converted = cast(list[dict[str, Any]], sampler_helpers.convert_cond(conditioning)) + comfy_samplers.resolve_areas_and_cond_masks_multidim( + converted, + tuple(int(dim) for dim in x.shape[2:]), + x.device, + ) + comfy_samplers.calculate_start_end_timesteps(model, converted) + if hasattr(model, "extra_conds"): + converted = cast( + list[dict[str, Any]], + comfy_samplers.encode_model_conds( + model.extra_conds, + converted, + x, + x.device, + "positive", + ), + ) + return converted + + +def _is_processed_conditioning(conditioning: object) -> bool: + """Return whether a conditioning value is already sampler-ready.""" + + if not isinstance(conditioning, list): + return False + if not conditioning: + return True + return all( + isinstance(item, dict) and "model_conds" in item for item in conditioning + ) + + +def _clean_model_options( + model_options: dict[str, Any], + existing_calc_cond_batch: CalcCondBatchFunction | None, +) -> dict[str, Any]: + """Return model options that cannot recurse into this wrapper.""" + + clean_options = model_options.copy() + if existing_calc_cond_batch is None: + clean_options.pop("sampler_calc_cond_batch_function", None) + else: + clean_options["sampler_calc_cond_batch_function"] = existing_calc_cond_batch + return clean_options + + +def _reject_unipc_sampler(sampler_name: str) -> None: + """Reject UniPC samplers because MultiDiffusion is incompatible with them.""" + + if sampler_name in UNIPC_SAMPLERS: + raise ValueError( + "Regional MultiDiffusion is not compatible with UniPC samplers." + ) + + +def _sampling_callback( + model: Any, + steps: int, + preview_context: DetailPreviewContext | None, +) -> Any: + """Return a generic or detailer-specific sampling preview callback.""" + + if preview_context is None: + return _latent_preview().prepare_callback(model, steps) + return prepare_detail_preview_callback(model, steps, preview_context) + + +def _comfy_sample() -> ModuleType: + """Import ComfyUI sample helpers lazily.""" + + return import_module("comfy.sample") + + +def _comfy_utils() -> ModuleType: + """Import ComfyUI utility state lazily.""" + + return import_module("comfy.utils") + + +def _comfy_samplers() -> ModuleType: + """Import ComfyUI sampler helpers lazily.""" + + return import_module("comfy.samplers") + + +def _comfy_sampler_helpers() -> ModuleType: + """Import ComfyUI conditioning conversion helpers lazily.""" + + return import_module("comfy.sampler_helpers") + + +def _latent_preview() -> ModuleType: + """Import ComfyUI preview helpers lazily.""" + + return import_module("latent_preview") diff --git a/simple_syrup/runtime/sam_loader.py b/simple_syrup/runtime/sam_loader.py new file mode 100644 index 0000000..23ce776 --- /dev/null +++ b/simple_syrup/runtime/sam_loader.py @@ -0,0 +1,215 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Load SAM models for ComfyUI model-loader nodes.""" + +from __future__ import annotations + +import importlib +from collections.abc import MutableMapping +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType +from typing import Any + +from ..shared.logging import get_logger +from .loaded_models import LoadedSAMModel +from .model_catalog import ModelEntry, get_sam_entry +from .model_device_manager import TorchModelDeviceManager +from .model_downloads import DownloadRequest, ModelDownloader, ProgressReporter +from .model_folders import ( + expected_model_file, + register_required_model_folders, + resolve_model_file, +) +from .model_instance_cache import ModelInstanceCache + +LOGGER = get_logger(__name__) +SAM_HQ_RUNTIME_PACKAGE = "simple_syrup.third_party.sam_hq_runtime" + + +@dataclass(frozen=True) +class SAMModelCacheKey: + """Identify a loaded SAM model for process-level reuse.""" + + model_id: str + model_type: str + checkpoint_path: Path + + +_LOADED_SAM_MODELS: dict[SAMModelCacheKey, LoadedSAMModel] = {} + + +class SAMLoaderService: + """Resolve, download, and load known SAM models.""" + + def __init__( + self, + downloader: ModelDownloader | None = None, + folder_paths_module: ModuleType | None = None, + device_manager: TorchModelDeviceManager | None = None, + cache: MutableMapping[SAMModelCacheKey, LoadedSAMModel] | None = None, + ) -> None: + """Create a SAM loader with injectable external boundaries.""" + + self._downloader = downloader or ModelDownloader() + self._folder_paths_module = folder_paths_module + self._device_manager = device_manager or TorchModelDeviceManager() + self._cache: ModelInstanceCache[SAMModelCacheKey, LoadedSAMModel] = ( + ModelInstanceCache(cache if cache is not None else _LOADED_SAM_MODELS) + ) + + def load_model( + self, + sam_model: str, + auto_download: bool, + progress: ProgressReporter | None = None, + ) -> LoadedSAMModel: + """Load a known SAM model and return a `SAM_MODEL`-compatible object.""" + + register_required_model_folders(self._folder_paths_module) + entry = get_sam_entry(sam_model) + artifact_paths = self._resolve_artifacts(entry, auto_download, progress) + checkpoint_path = artifact_paths[0] + key = SAMModelCacheKey( + model_id=entry.entry_id, + model_type=entry.model_type, + checkpoint_path=checkpoint_path.resolve(), + ) + already_loaded = key in self._cache.entries + loaded = self._cache.get_or_load( + key, + lambda: self._load_uncached_model(entry, checkpoint_path), + ) + if already_loaded: + LOGGER.info( + "SAM model loaded from process cache", + extra={ + "operation": "sam_loader", + "model": entry.entry_id, + "model_type": entry.model_type, + "checkpoint_path": str(checkpoint_path), + }, + ) + return loaded + + def _load_uncached_model( + self, + entry: ModelEntry, + checkpoint_path: Path, + ) -> LoadedSAMModel: + """Load and wrap a SAM model after artifact resolution and cache lookup.""" + + model = self._load_segment_anything_model(entry, checkpoint_path) + managed_model = self._device_manager.manage( + model, + model_id=entry.entry_id, + source=str(checkpoint_path), + ) + LOGGER.info( + "SAM model loaded", + extra={ + "operation": "sam_loader", + "model": entry.entry_id, + "model_type": entry.model_type, + "checkpoint_path": str(checkpoint_path), + }, + ) + return LoadedSAMModel( + model=model, + source=str(checkpoint_path), + model_id=entry.entry_id, + managed_model=managed_model, + ) + + def _resolve_artifacts( + self, + entry: ModelEntry, + auto_download: bool, + progress: ProgressReporter | None, + ) -> list[Path]: + """Resolve or download every SAM artifact for a catalog entry.""" + + paths: list[Path] = [] + for artifact in entry.artifacts: + existing = resolve_model_file( + artifact.folder_name, + artifact.filename, + self._folder_paths_module, + ) + if existing is not None: + paths.append(existing) + continue + + destination = expected_model_file( + artifact.folder_name, + artifact.filename, + self._folder_paths_module, + ) + if not auto_download or not entry.auto_download_allowed: + raise FileNotFoundError( + f"SAM model '{entry.display_name}' is missing and " + f"auto_download is disabled. Expected: {destination}. " + "Enable auto_download on SAM Model Loader or install the " + "model file." + ) + result = self._downloader.download( + DownloadRequest( + source_url=artifact.source_url, + destination_path=destination, + expected_folder=destination.parent, + description=artifact.description, + ), + progress, + ) + paths.append(result.path) + return paths + + def _load_segment_anything_model( + self, + entry: ModelEntry, + checkpoint_path: Path, + ) -> object: + """Load a SAM model from the segment-anything registry.""" + + try: + importlib.invalidate_caches() + runtime_module = importlib.import_module( + _registry_module_name(entry.model_type) + ) + except ImportError as error: + raise RuntimeError( + _registry_import_error_message(entry.model_type, error) + ) from error + + registry: dict[str, Any] = runtime_module.sam_model_registry + model = registry[entry.model_type](checkpoint=str(checkpoint_path)) + model.model_name = checkpoint_path.name + return model + + +def _registry_module_name(model_type: str) -> str: + """Return the registry module that owns one SAM-compatible model type.""" + + if model_type.startswith("sam_hq") or model_type == "mobile_sam": + return f"{SAM_HQ_RUNTIME_PACKAGE}.build_sam_hq" + return "segment_anything" + + +def _registry_import_error_message(model_type: str, error: ImportError) -> str: + """Return an actionable registry import failure for a SAM model type.""" + + if model_type.startswith("sam_hq"): + return ( + "SAM-HQ support requires SimpleSyrup's bundled SAM-HQ runtime and " + "its dependencies. Reinstall SimpleSyrup or restore " + f"{SAM_HQ_RUNTIME_PACKAGE}. Import failed: {error}." + ) + if model_type == "mobile_sam": + return ( + "MobileSAM support requires SimpleSyrup's bundled SAM-HQ/MobileSAM " + "runtime and its dependencies. Reinstall SimpleSyrup or restore " + f"{SAM_HQ_RUNTIME_PACKAGE}. Import failed: {error}." + ) + return f"segment-anything is required to load SAM models. Import failed: {error}." diff --git a/simple_syrup/runtime/sam_segmenter.py b/simple_syrup/runtime/sam_segmenter.py new file mode 100644 index 0000000..7486a03 --- /dev/null +++ b/simple_syrup/runtime/sam_segmenter.py @@ -0,0 +1,209 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Adapt SAM-compatible model objects into box segmentation.""" + +from __future__ import annotations + +import importlib +from typing import Any, Protocol, cast + +import numpy as np +import torch +from numpy.typing import NDArray + +from .loaded_models import LoadedSAMModel, unwrap_sam_model +from .model_device_manager import TorchModelDeviceManager, external_model_inference +from .sam_loader import SAM_HQ_RUNTIME_PACKAGE + + +class SAMBoxSegmenter(Protocol): + """Segment image boxes into one mask per box.""" + + def segment_boxes( + self, + sam_model: object, + image: torch.Tensor, + boxes: torch.Tensor, + threshold: float, + execution_device: str, + ) -> tuple[torch.Tensor, ...]: + """Return one HW mask per input box.""" + + +class SAMModelSegmenter: + """Segment boxes with SimpleSyrup, Impact, or raw SAM model objects.""" + + def segment_boxes( + self, + sam_model: object, + image: torch.Tensor, + boxes: torch.Tensor, + threshold: float, + execution_device: str, + ) -> tuple[torch.Tensor, ...]: + """Return one HW mask per provided box.""" + + if boxes.numel() == 0: + return () + + model = unwrap_sam_model(sam_model) + image_array = _tensor_to_rgb_array(image) + wrapper = getattr(model, "sam_wrapper", None) + if wrapper is not None: + return _predict_with_wrapper(wrapper, image_array, boxes, threshold) + + predict = getattr(model, "predict", None) + if callable(predict): + return _predict_with_wrapper(model, image_array, boxes, threshold) + + if ( + isinstance(sam_model, LoadedSAMModel) + and sam_model.managed_model is not None + ): + use_hq_predictor = _uses_sam_hq_predictor(sam_model) + with TorchModelDeviceManager().inference( + sam_model.managed_model, + execution_device, + ) as loaded: + return _predict_with_raw_segment_anything( + loaded.model, + image_array, + boxes, + loaded.device, + use_hq_predictor=use_hq_predictor, + ) + + with external_model_inference(model, execution_device) as loaded: + return _predict_with_raw_segment_anything( + loaded.model, + image_array, + boxes, + loaded.device, + use_hq_predictor=_uses_sam_hq_predictor(model), + ) + + +def _predict_with_wrapper( + wrapper: object, + image_array: NDArray[np.uint8], + boxes: torch.Tensor, + threshold: float, +) -> tuple[torch.Tensor, ...]: + """Segment boxes with Impact-style wrapper objects.""" + + prepare_device = getattr(wrapper, "prepare_device", None) + release_device = getattr(wrapper, "release_device", None) + if callable(prepare_device): + prepare_device() + try: + masks: list[torch.Tensor] = [] + predict = getattr(wrapper, "predict", None) + if not callable(predict): + raise TypeError( + "SAM_MODEL is not compatible with Prompt SEGS w/ SAM. Expected an " + "object with sam_wrapper, predict(...), or a raw Segment Anything " + "model." + ) + for box in boxes.cpu().tolist(): + predicted = predict(image_array, [], [], box, threshold) + candidate_masks = _normalize_predicted_masks(predicted) + if candidate_masks: + masks.append( + torch.stack(candidate_masks).float().amax(dim=0).clamp(0.0, 1.0) + ) + else: + masks.append(torch.zeros(image_array.shape[:2], dtype=torch.float32)) + return tuple(masks) + finally: + if callable(release_device): + release_device() + + +def _predict_with_raw_segment_anything( + model: object, + image_array: NDArray[np.uint8], + boxes: torch.Tensor, + device: torch.device, + *, + use_hq_predictor: bool, +) -> tuple[torch.Tensor, ...]: + """Segment boxes with a Segment Anything predictor API.""" + + predictor = _create_raw_sam_predictor(model, use_hq_predictor) + predictor.set_image(image_array) + transformed_boxes = predictor.transform.apply_boxes_torch( + boxes.cpu(), + image_array.shape[:2], + ) + masks, _scores, _logits = predictor.predict_torch( + point_coords=None, + point_labels=None, + boxes=transformed_boxes.to(device), + multimask_output=False, + ) + box_masks = cast(torch.Tensor, masks[:, 0, :, :].detach().cpu().float()) + return tuple(mask.clamp(0.0, 1.0) for mask in box_masks) + + +def _create_raw_sam_predictor(model: object, use_hq_predictor: bool) -> Any: + """Create the predictor matching the loaded SAM model family.""" + + try: + if use_hq_predictor: + predictor_module = importlib.import_module( + f"{SAM_HQ_RUNTIME_PACKAGE}.predictor" + ) + predictor_class = cast(Any, predictor_module).SamPredictorHQ + return predictor_class(model, True) + + segment_anything = importlib.import_module("segment_anything") + return segment_anything.SamPredictor(model) + except ImportError as error: + raise RuntimeError( + "SAM_MODEL is not compatible with Prompt SEGS w/ SAM. Expected an object " + "with sam_wrapper, predict(...), or a raw Segment Anything model with " + "its required runtime package installed." + ) from error + + +def _uses_sam_hq_predictor(model: object) -> bool: + """Return whether a model container or raw object needs the HQ predictor.""" + + if isinstance(model, LoadedSAMModel): + return model.model_id.startswith("sam_hq") + model_name = getattr(model, "model_name", "") + return isinstance(model_name, str) and model_name.startswith("sam_hq") + + +def _normalize_predicted_masks(predicted: object) -> list[torch.Tensor]: + """Normalize wrapper prediction output into HW float tensors.""" + + if predicted is None: + return [] + raw_items = predicted if isinstance(predicted, list | tuple) else [predicted] + masks: list[torch.Tensor] = [] + for item in raw_items: + tensor = torch.as_tensor(item, dtype=torch.float32).detach().cpu() + while tensor.ndim > 2 and 1 in tensor.shape: + tensor = tensor.squeeze(0) + if tensor.ndim == 3: + tensor = tensor.amax(dim=0) + if tensor.ndim != 2: + raise ValueError( + f"SAM_MODEL returned an invalid mask shape: {tuple(tensor.shape)}." + ) + masks.append(tensor.clamp(0.0, 1.0)) + return masks + + +def _tensor_to_rgb_array(image: torch.Tensor) -> NDArray[np.uint8]: + """Convert a ComfyUI HWC image sample to uint8 RGB.""" + + array = image.detach().cpu().float().clamp(0.0, 1.0).numpy() + if array.shape[-1] == 1: + array = np.repeat(array, 3, axis=-1) + if array.shape[-1] == 4: + array = array[..., :3] + return (array * 255.0).round().astype(np.uint8) diff --git a/simple_syrup/runtime/sampling_samplers.py b/simple_syrup/runtime/sampling_samplers.py new file mode 100644 index 0000000..803e92a --- /dev/null +++ b/simple_syrup/runtime/sampling_samplers.py @@ -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 +# +# Portions of this file integrate AUTOMATIC1111 sampler behavior. See +# third_party/manifest.toml and third_party/NOTICE.md. + +"""Sampler resolution helpers for SimpleSyrup sampling nodes.""" + +from __future__ import annotations + +from collections.abc import Sequence +from importlib import import_module +from types import ModuleType +from typing import Protocol, cast + +from ..shared.logging import get_logger +from .a1111_sampling import sample_euler_ancestral_a1111 + +LOGGER = get_logger(__name__) +EXTRA_SAMPLERS = ("euler_a_a1111",) + + +class SamplerObject(Protocol): + """Represent the executable sampler object returned by ComfyUI.""" + + def sample(self, *args: object, **kwargs: object) -> object: + """Run the ComfyUI sampler.""" + + +def available_samplers() -> tuple[str, ...]: + """Return core ComfyUI samplers plus locally resolved extra samplers.""" + + comfy_samplers = _comfy_samplers() + core_samplers = tuple(str(name) for name in comfy_samplers.KSampler.SAMPLERS) + return _unique_sampler_names(core_samplers + EXTRA_SAMPLERS) + + +def resolve_sampler(sampler_name: str) -> SamplerObject: + """Return a ComfyUI sampler object for a validated sampler name.""" + + supported_samplers = available_samplers() + if sampler_name not in supported_samplers: + supported = ", ".join(supported_samplers) + LOGGER.error( + "Unsupported sampler requested", + extra={ + "operation": "resolve_sampler", + "sampler_name": sampler_name, + "supported_samplers": supported, + }, + ) + raise ValueError( + f"Unsupported sampler '{sampler_name}'. Supported samplers are: {supported}" + ) + + if sampler_name in EXTRA_SAMPLERS: + return _resolve_extra_sampler(sampler_name) + + return cast(SamplerObject, _comfy_samplers().sampler_object(sampler_name)) + + +def _resolve_extra_sampler(sampler_name: str) -> SamplerObject: + """Resolve a SimpleSyrup-owned sampler object.""" + + if sampler_name == "euler_a_a1111": + return cast( + SamplerObject, + _comfy_samplers().KSAMPLER(sample_euler_ancestral_a1111), + ) + raise ValueError(f"Unsupported extra sampler '{sampler_name}'.") + + +def _unique_sampler_names(names: Sequence[str]) -> tuple[str, ...]: + """Return sampler names in first-seen order without duplicates.""" + + unique_names: list[str] = [] + seen: set[str] = set() + for name in names: + if name in seen: + continue + unique_names.append(name) + seen.add(name) + return tuple(unique_names) + + +def _comfy_samplers() -> ModuleType: + """Import ComfyUI samplers lazily to keep registration imports lightweight.""" + + return import_module("comfy.samplers") diff --git a/simple_syrup/runtime/sampling_schedulers.py b/simple_syrup/runtime/sampling_schedulers.py new file mode 100644 index 0000000..2fe7539 --- /dev/null +++ b/simple_syrup/runtime/sampling_schedulers.py @@ -0,0 +1,590 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Portions of this file are adapted from RES4LYF and AUTOMATIC1111 scheduler +# behavior. See third_party/manifest.toml and third_party/NOTICE.md. + +"""Scheduler sigma policies for SimpleSyrup sampling nodes.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from importlib import import_module +from types import ModuleType +from typing import Protocol, cast + +import torch + +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + +EXTRA_SCHEDULERS = ("AYS SD1", "AYS SDXL", "GITS", "beta57", "automatic_a1111") +GITS_DEFAULT_COEFF = 1.20 +BETA57_ALPHA = 0.5 +BETA57_BETA = 0.7 + +AYS_NOISE_LEVELS: dict[str, tuple[float, ...]] = { + "SD1": ( + 14.6146412293, + 6.4745760956, + 3.8636745985, + 2.6946151520, + 1.8841921177, + 1.3943805092, + 0.9642583904, + 0.6523686016, + 0.3977456272, + 0.1515232662, + 0.0291671582, + ), + "SDXL": ( + 14.6146412293, + 6.3184485287, + 3.7681790315, + 2.1811480769, + 1.3405244945, + 0.8620721141, + 0.5550693289, + 0.3798540708, + 0.2332364134, + 0.1114188177, + 0.0291671582, + ), +} + +GITS_DEFAULT_NOISE_LEVELS: tuple[tuple[float, ...], ...] = ( + (14.61464119, 0.803307, 0.02916753), + (14.61464119, 1.56271636, 0.52423614, 0.02916753), + (14.61464119, 2.36326075, 0.92192322, 0.36617002, 0.02916753), + (14.61464119, 2.84484982, 1.24153244, 0.59516323, 0.25053367, 0.02916753), + ( + 14.61464119, + 5.85520077, + 2.05039096, + 0.95350921, + 0.45573691, + 0.17026083, + 0.02916753, + ), + ( + 14.61464119, + 5.85520077, + 2.45070267, + 1.24153244, + 0.64427125, + 0.29807833, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 5.85520077, + 2.45070267, + 1.36964464, + 0.803307, + 0.45573691, + 0.25053367, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 5.85520077, + 2.84484982, + 1.61558151, + 0.95350921, + 0.59516323, + 0.36617002, + 0.19894916, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 5.85520077, + 2.84484982, + 1.67050016, + 1.08895338, + 0.74807048, + 0.50118381, + 0.32104823, + 0.19894916, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 5.85520077, + 2.95596409, + 1.84880662, + 1.24153244, + 0.83188516, + 0.59516323, + 0.41087446, + 0.27464288, + 0.17026083, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 5.85520077, + 3.07277966, + 1.98035145, + 1.36964464, + 0.95350921, + 0.69515091, + 0.50118381, + 0.36617002, + 0.25053367, + 0.17026083, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 6.77309084, + 3.46139455, + 2.36326075, + 1.56271636, + 1.08895338, + 0.803307, + 0.59516323, + 0.45573691, + 0.34370604, + 0.25053367, + 0.17026083, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 6.77309084, + 3.46139455, + 2.45070267, + 1.61558151, + 1.162866, + 0.86115354, + 0.64427125, + 0.50118381, + 0.38853383, + 0.29807833, + 0.22545385, + 0.17026083, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 7.49001646, + 4.65472794, + 3.07277966, + 2.12350607, + 1.51179266, + 1.08895338, + 0.83188516, + 0.64427125, + 0.50118381, + 0.38853383, + 0.29807833, + 0.22545385, + 0.17026083, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 7.49001646, + 4.65472794, + 3.07277966, + 2.12350607, + 1.51179266, + 1.08895338, + 0.83188516, + 0.64427125, + 0.50118381, + 0.41087446, + 0.32104823, + 0.25053367, + 0.19894916, + 0.13792117, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 7.49001646, + 4.65472794, + 3.07277966, + 2.12350607, + 1.51179266, + 1.08895338, + 0.83188516, + 0.64427125, + 0.50118381, + 0.41087446, + 0.34370604, + 0.27464288, + 0.22545385, + 0.17026083, + 0.13792117, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 7.49001646, + 4.65472794, + 3.07277966, + 2.19988537, + 1.61558151, + 1.20157266, + 0.92192322, + 0.72133851, + 0.57119018, + 0.45573691, + 0.36617002, + 0.29807833, + 0.25053367, + 0.19894916, + 0.17026083, + 0.13792117, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 7.49001646, + 4.65472794, + 3.07277966, + 2.19988537, + 1.61558151, + 1.24153244, + 0.95350921, + 0.74807048, + 0.59516323, + 0.4783645, + 0.38853383, + 0.32104823, + 0.27464288, + 0.22545385, + 0.19894916, + 0.17026083, + 0.13792117, + 0.09824532, + 0.02916753, + ), + ( + 14.61464119, + 7.49001646, + 4.65472794, + 3.07277966, + 2.19988537, + 1.61558151, + 1.24153244, + 0.95350921, + 0.74807048, + 0.59516323, + 0.50118381, + 0.41087446, + 0.34370604, + 0.29807833, + 0.25053367, + 0.22545385, + 0.19894916, + 0.17026083, + 0.13792117, + 0.09824532, + 0.02916753, + ), +) + + +class SamplingModel(Protocol): + """Expose the ComfyUI model sampling object needed for core schedulers.""" + + def get_model_object(self, name: str) -> object: + """Return a named ComfyUI model object.""" + + +def available_schedulers() -> tuple[str, ...]: + """Return core ComfyUI schedulers plus locally resolved extra schedulers.""" + + comfy_samplers = _comfy_samplers() + core_schedulers = tuple(str(name) for name in comfy_samplers.KSampler.SCHEDULERS) + return _unique_scheduler_names(core_schedulers + EXTRA_SCHEDULERS) + + +def calculate_sigmas( + model: SamplingModel, + scheduler_name: str, + sampler_name: str, + steps: int, + denoise: float, +) -> torch.Tensor: + """Calculate sigmas for a core or SimpleSyrup-owned scheduler.""" + + supported_schedulers = available_schedulers() + if scheduler_name not in supported_schedulers: + supported = ", ".join(supported_schedulers) + LOGGER.error( + "Unsupported scheduler requested", + extra={ + "operation": "calculate_sigmas", + "scheduler_name": scheduler_name, + "sampler_name": sampler_name, + "supported_schedulers": supported, + }, + ) + raise ValueError( + f"Unsupported scheduler '{scheduler_name}'. " + f"Supported schedulers are: {supported}" + ) + + if scheduler_name in EXTRA_SCHEDULERS: + return _calculate_extra_sigmas( + model=model, + scheduler_name=scheduler_name, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + ) + + return _calculate_core_sigmas( + model=model, + scheduler_name=scheduler_name, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + ) + + +def _calculate_core_sigmas( + model: SamplingModel, + scheduler_name: str, + sampler_name: str, + steps: int, + denoise: float, +) -> torch.Tensor: + """Calculate sigmas with ComfyUI's core scheduler policy.""" + + if denoise <= 0.0: + return torch.FloatTensor([]) + + schedule_steps = _calculate_schedule_steps(steps, denoise) + sigmas = _calculate_core_sigmas_for_steps( + model=model, + scheduler_name=scheduler_name, + sampler_name=sampler_name, + steps=schedule_steps, + ) + return _apply_ksampler_denoise(sigmas, steps, denoise) + + +def _calculate_core_sigmas_for_steps( + model: SamplingModel, + scheduler_name: str, + sampler_name: str, + steps: int, +) -> torch.Tensor: + """Apply ComfyUI scheduler calculation and sampler-specific sigma cleanup.""" + + discard_penultimate_sigma = _discards_penultimate_sigma(sampler_name) + calculation_steps = steps + 1 if discard_penultimate_sigma else steps + comfy_samplers = _comfy_samplers() + sigmas = cast( + torch.Tensor, + comfy_samplers.calculate_sigmas( + model.get_model_object("model_sampling"), + scheduler_name, + calculation_steps, + ), + ) + if discard_penultimate_sigma: + return torch.cat([sigmas[:-2], sigmas[-1:]]) + return sigmas + + +def _calculate_extra_sigmas( + model: SamplingModel, + scheduler_name: str, + sampler_name: str, + steps: int, + denoise: float, +) -> torch.Tensor: + """Calculate sigmas for locally resolved extra scheduler policies.""" + + if denoise <= 0.0: + return torch.FloatTensor([]) + + schedule_steps = _calculate_schedule_steps(steps, denoise) + discard_penultimate_sigma = _discards_penultimate_sigma(sampler_name) + calculation_steps = ( + schedule_steps + 1 if discard_penultimate_sigma else schedule_steps + ) + sigmas = _calculate_extra_schedule(model, scheduler_name, calculation_steps) + + if discard_penultimate_sigma: + sigmas = torch.cat([sigmas[:-2], sigmas[-1:]]) + return _apply_ksampler_denoise(sigmas, steps, denoise) + + +def _calculate_schedule_steps(steps: int, denoise: float) -> int: + """Return KSampler's expanded step count for full schedule generation.""" + + if denoise > 0.9999: + return steps + return int(steps / denoise) + + +def _apply_ksampler_denoise( + sigmas: torch.Tensor, + steps: int, + denoise: float, +) -> torch.Tensor: + """Apply KSampler's partial-denoise tail truncation.""" + + if denoise > 0.9999: + return sigmas + return sigmas[-(steps + 1) :] + + +def _calculate_extra_schedule( + model: SamplingModel, + scheduler_name: str, + steps: int, +) -> torch.Tensor: + """Calculate a full local extra scheduler output.""" + + if scheduler_name == "AYS SD1": + return _calculate_ays_schedule("SD1", steps) + if scheduler_name == "AYS SDXL": + return _calculate_ays_schedule("SDXL", steps) + if scheduler_name == "GITS": + return _calculate_gits_schedule(steps) + if scheduler_name == "beta57": + return _calculate_beta57_schedule(model, steps) + if scheduler_name == "automatic_a1111": + return _calculate_automatic_a1111_schedule(model, steps) + raise ValueError(f"Unsupported extra scheduler '{scheduler_name}'.") + + +def _calculate_ays_schedule(model_type: str, steps: int) -> torch.Tensor: + """Calculate full AYS sigmas for the requested step count.""" + + sigmas = list(AYS_NOISE_LEVELS[model_type]) + if (steps + 1) != len(sigmas): + sigmas = _loglinear_interpolate(sigmas, steps + 1) + sigmas[-1] = 0.0 + return torch.FloatTensor(sigmas) + + +def _calculate_gits_schedule(steps: int) -> torch.Tensor: + """Calculate full GITS sigmas using default coeff 1.20.""" + + if steps <= 20: + sigmas = list(GITS_DEFAULT_NOISE_LEVELS[steps - 2]) + else: + sigmas = _loglinear_interpolate(GITS_DEFAULT_NOISE_LEVELS[-1], steps + 1) + sigmas[-1] = 0.0 + return torch.FloatTensor(sigmas) + + +def _calculate_beta57_schedule(model: SamplingModel, steps: int) -> torch.Tensor: + """Calculate RES4LYF's vendored beta57 preset with ComfyUI beta scheduling.""" + + comfy_samplers = _comfy_samplers() + return cast( + torch.Tensor, + comfy_samplers.beta_scheduler( + model.get_model_object("model_sampling"), + steps, + alpha=BETA57_ALPHA, + beta=BETA57_BETA, + ), + ) + + +def _calculate_automatic_a1111_schedule( + model: SamplingModel, + steps: int, +) -> torch.Tensor: + """Calculate A1111's automatic k-diffusion discrete sigma schedule.""" + + model_sampling = model.get_model_object("model_sampling") + model_sigmas = getattr(model_sampling, "sigmas", None) + sigma_converter = getattr(model_sampling, "sigma", None) + if not isinstance(model_sigmas, torch.Tensor) or not callable(sigma_converter): + raise ValueError( + "automatic_a1111 requires a ComfyUI model_sampling object with " + "tensor sigmas and a callable sigma(timestep) converter." + ) + if len(model_sigmas) == 0: + raise ValueError("automatic_a1111 requires at least one model sigma.") + + timesteps = torch.linspace( + len(model_sigmas) - 1, + 0, + steps, + device=model_sigmas.device, + ) + sigmas = sigma_converter(timesteps) + if not isinstance(sigmas, torch.Tensor): + raise ValueError( + "automatic_a1111 model_sampling.sigma(timestep) must return a tensor." + ) + return torch.cat([sigmas, sigmas.new_zeros([1])]).detach().cpu() + + +def _loglinear_interpolate(sigmas: Sequence[float], num_steps: int) -> list[float]: + """Interpolate decreasing sigma values in log space.""" + + if num_steps <= 1: + return [float(sigmas[0])] + + reversed_logs = [math.log(value) for value in reversed(sigmas)] + source_max = len(reversed_logs) - 1 + target_max = num_steps - 1 + interpolated: list[float] = [] + + for target_index in range(num_steps): + source_position = target_index * source_max / target_max + left_index = math.floor(source_position) + right_index = min(left_index + 1, source_max) + fraction = source_position - left_index + left_value = reversed_logs[left_index] + right_value = reversed_logs[right_index] + interpolated.append( + math.exp(left_value + (right_value - left_value) * fraction) + ) + + return list(reversed(interpolated)) + + +def _discards_penultimate_sigma(sampler_name: str) -> bool: + """Return whether ComfyUI drops the penultimate sigma for this sampler.""" + + comfy_samplers = _comfy_samplers() + return sampler_name in comfy_samplers.KSampler.DISCARD_PENULTIMATE_SIGMA_SAMPLERS + + +def _unique_scheduler_names(names: Sequence[str]) -> tuple[str, ...]: + """Return scheduler names in first-seen order without duplicates.""" + + unique_names: list[str] = [] + seen: set[str] = set() + for name in names: + if name in seen: + continue + unique_names.append(name) + seen.add(name) + return tuple(unique_names) + + +def _comfy_samplers() -> ModuleType: + """Import ComfyUI samplers lazily to keep registration imports lightweight.""" + + return import_module("comfy.samplers") diff --git a/simple_syrup/runtime/settings.py b/simple_syrup/runtime/settings.py new file mode 100644 index 0000000..182d79e --- /dev/null +++ b/simple_syrup/runtime/settings.py @@ -0,0 +1,136 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Persistent backend settings for SimpleSyrup runtime behavior.""" + +from __future__ import annotations + +import importlib +import json +from dataclasses import dataclass +from json import JSONDecodeError +from pathlib import Path +from types import ModuleType +from typing import Any, Final + +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) +SETTINGS_FILENAME: Final = "settings.json" + + +class SimpleSyrupSettingsError(ValueError): + """Raised when SimpleSyrup settings data is malformed.""" + + +@dataclass(frozen=True) +class SimpleSyrupSettings: + """User-configurable SimpleSyrup runtime settings.""" + + show_downloadable_models: bool = True + + def to_payload(self) -> dict[str, bool]: + """Return the validated JSON payload shape.""" + + return {"show_downloadable_models": self.show_downloadable_models} + + @classmethod + def from_payload(cls, payload: object) -> SimpleSyrupSettings: + """Create settings from a decoded JSON payload.""" + + if not isinstance(payload, dict): + raise SimpleSyrupSettingsError( + "SimpleSyrup settings payload must be a JSON object." + ) + + value = payload.get("show_downloadable_models") + if not isinstance(value, bool): + raise SimpleSyrupSettingsError( + "SimpleSyrup settings payload is invalid. Expected " + "show_downloadable_models to be a boolean." + ) + return cls(show_downloadable_models=value) + + +class SimpleSyrupSettingsRepository: + """Load and save SimpleSyrup settings from Comfy's user directory.""" + + def __init__( + self, + settings_path: Path | None = None, + folder_paths_module: ModuleType | None = None, + ) -> None: + """Create a repository with injectable filesystem and Comfy boundaries.""" + + self._settings_path = settings_path + self._folder_paths_module = folder_paths_module + + def load(self) -> SimpleSyrupSettings: + """Load settings or return defaults for missing/malformed files.""" + + path = self.settings_path() + if not path.is_file(): + return SimpleSyrupSettings() + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + return SimpleSyrupSettings.from_payload(payload) + except (JSONDecodeError, OSError, SimpleSyrupSettingsError) as error: + LOGGER.warning( + "using default settings after failed load", + extra={"settings_path": str(path), "reason": str(error)}, + ) + return SimpleSyrupSettings() + + def save(self, settings: SimpleSyrupSettings) -> SimpleSyrupSettings: + """Persist validated settings and return the saved value.""" + + path = self.settings_path() + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = path.with_name(f"{path.name}.tmp") + temporary_path.write_text( + json.dumps(settings.to_payload(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary_path.replace(path) + return settings + + def settings_path(self) -> Path: + """Return the resolved settings path.""" + + if self._settings_path is not None: + return self._settings_path + + folder_paths = self._folder_paths_module or _folder_paths() + return ( + _user_directory(folder_paths) + / "default" + / "SimpleSyrup" + / SETTINGS_FILENAME + ) + + +def _user_directory(folder_paths: ModuleType) -> Path: + """Return Comfy's user directory from stable APIs or conservative fallback.""" + + get_user_directory = getattr(folder_paths, "get_user_directory", None) + if callable(get_user_directory): + user_directory = get_user_directory() + return Path(str(user_directory)) + + user_directory_attribute = getattr(folder_paths, "user_directory", None) + if user_directory_attribute is not None: + return Path(str(user_directory_attribute)) + + models_dir: Any = folder_paths.models_dir + return Path(str(models_dir)).parent / "user" + + +def _folder_paths() -> ModuleType: + """Import ComfyUI folder paths lazily.""" + + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + return module diff --git a/simple_syrup/runtime/settings_routes.py b/simple_syrup/runtime/settings_routes.py new file mode 100644 index 0000000..fef5aa5 --- /dev/null +++ b/simple_syrup/runtime/settings_routes.py @@ -0,0 +1,115 @@ +# 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 SimpleSyrup backend settings.""" + +from __future__ import annotations + +import sys +from collections.abc import Callable, Coroutine +from typing import Any, Protocol, cast + +from aiohttp import web + +from ..shared.logging import get_logger +from .settings import ( + SimpleSyrupSettings, + SimpleSyrupSettingsError, + SimpleSyrupSettingsRepository, +) + +LOGGER = get_logger(__name__) +SETTINGS_ROUTE = "/simple-syrup/settings" + +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 settings 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.""" + + +class PromptServerProtocol(Protocol): + """Subset of Comfy's PromptServer needed for route registration.""" + + routes: RoutesProtocol + + +class SettingsHandlers: + """Handle SimpleSyrup settings HTTP requests.""" + + def __init__(self, repository: SimpleSyrupSettingsRepository) -> None: + """Create handlers backed by the settings repository.""" + + self._repository = repository + + async def get_settings(self, _request: Any) -> web.Response: + """Return current SimpleSyrup settings.""" + + return web.json_response(self._repository.load().to_payload()) + + async def post_settings(self, request: Any) -> web.Response: + """Validate and persist SimpleSyrup settings.""" + + try: + payload = await request.json() + settings = SimpleSyrupSettings.from_payload(payload) + except SimpleSyrupSettingsError as error: + return web.json_response({"error": str(error)}, status=400) + except Exception as error: + LOGGER.warning( + "invalid settings request body", + extra={"route": SETTINGS_ROUTE, "reason": str(error)}, + ) + return web.json_response( + {"error": "SimpleSyrup settings request body must be valid JSON."}, + status=400, + ) + + saved = self._repository.save(settings) + return web.json_response(saved.to_payload()) + + +def register_settings_routes( + repository: SimpleSyrupSettingsRepository | None = None, + prompt_server: PromptServerProtocol | None = None, +) -> bool: + """Register SimpleSyrup settings 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 = SettingsHandlers(repository or SimpleSyrupSettingsRepository()) + server_instance.routes.get(SETTINGS_ROUTE)(handlers.get_settings) + server_instance.routes.post(SETTINGS_ROUTE)(handlers.post_settings) + 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 settings routes", + extra={"reason": str(error)}, + ) + return None + return cast(PromptServerProtocol, instance) diff --git a/simple_syrup/runtime/text_box_detector.py b/simple_syrup/runtime/text_box_detector.py new file mode 100644 index 0000000..b8bd421 --- /dev/null +++ b/simple_syrup/runtime/text_box_detector.py @@ -0,0 +1,238 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Adapt GroundingDINO-compatible objects into text-prompt box detection.""" + +from __future__ import annotations + +import importlib +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, Protocol + +import torch +from PIL import Image + +from ..domain.segs import BoundingBox +from .grounding_dino_loader import GROUNDING_DINO_RUNTIME_PACKAGE +from .loaded_models import LoadedGroundingDINOModel, unwrap_grounding_dino_model +from .model_device_manager import ( + LoadedManagedModel, + TorchModelDeviceManager, + external_model_inference, +) + + +@dataclass(frozen=True) +class TextBoxDetection: + """Represent one prompt box detection with confidence.""" + + bbox: BoundingBox + confidence: float + + +class TextBoxDetector(Protocol): + """Detect pixel-space boxes from a text prompt.""" + + def detect( + self, + grounding_dino_model: object, + image: torch.Tensor, + prompt: str, + threshold: float, + execution_device: str, + ) -> tuple[TextBoxDetection, ...]: + """Return filtered prompt detections in pixel coordinates.""" + + +class GroundingDINOTextBoxDetector: + """Detect prompt boxes using protocol objects or raw GroundingDINO models.""" + + def detect( + self, + grounding_dino_model: object, + image: torch.Tensor, + prompt: str, + threshold: float, + execution_device: str, + ) -> tuple[TextBoxDetection, ...]: + """Return filtered prompt detections in pixel coordinates.""" + + if ( + isinstance(grounding_dino_model, LoadedGroundingDINOModel) + and grounding_dino_model.managed_model is not None + ): + with _managed_inference( + grounding_dino_model, + execution_device, + ) as loaded: + return _detect_with_raw_grounding_dino( + loaded.model, + image, + prompt, + threshold, + loaded.device, + ) + + model = unwrap_grounding_dino_model(grounding_dino_model) + predict_boxes = getattr(model, "predict_boxes", None) + if callable(predict_boxes): + boxes = torch.as_tensor( + predict_boxes(image, prompt, threshold), + dtype=torch.float32, + ) + boxes = _validate_boxes(boxes) + scores = torch.ones((int(boxes.shape[0]),), dtype=torch.float32) + return _detections_from_boxes( + boxes, + scores, + image_height=int(image.shape[0]), + image_width=int(image.shape[1]), + ) + + if callable(model): + with external_model_inference(model, execution_device) as loaded: + return _detect_with_raw_grounding_dino( + loaded.model, + image, + prompt, + threshold, + loaded.device, + ) + + raise TypeError( + "GroundingDINO model is not compatible with Prompt SEGS w/ SAM. Expected " + "GROUNDING_DINO_MODEL or DINO_MODEL with predict_boxes(...) or a callable " + "GroundingDINO model." + ) + + +def _detect_with_raw_grounding_dino( + model: Any, + image: torch.Tensor, + prompt: str, + threshold: float, + device: torch.device, +) -> tuple[TextBoxDetection, ...]: + """Run GroundingDINO inference for a raw callable model object.""" + + try: + transforms_module = importlib.import_module( + f"{GROUNDING_DINO_RUNTIME_PACKAGE}.datasets.transforms" + ) + except ImportError as error: + raise RuntimeError( + "GroundingDINO inference dependencies are missing." + ) from error + + pil_image = _tensor_to_pil(image) + transform = transforms_module.Compose( + [ + transforms_module.RandomResize([800], max_size=1333), + transforms_module.ToTensor(), + transforms_module.Normalize( + [0.485, 0.456, 0.406], + [0.229, 0.224, 0.225], + ), + ] + ) + dino_image, _unused = transform(pil_image.convert("RGB"), None) + caption = prompt.lower().strip() + if not caption.endswith("."): + caption = f"{caption}." + with torch.no_grad(): + outputs: dict[str, torch.Tensor] = model( + dino_image.to(device)[None], + captions=[caption], + ) + logits = outputs["pred_logits"].sigmoid()[0] + boxes = outputs["pred_boxes"][0] + scores = logits.max(dim=1)[0] + keep = scores > threshold + boxes = boxes[keep].cpu() + scores = scores[keep].cpu() + width, height = pil_image.size + for index in range(boxes.size(0)): + boxes[index] = boxes[index] * torch.tensor([width, height, width, height]) + boxes[index][:2] -= boxes[index][2:] / 2 + boxes[index][2:] += boxes[index][:2] + return _detections_from_boxes( + _validate_boxes(boxes), + scores.float(), + image_height=height, + image_width=width, + ) + + +def _validate_boxes(boxes: torch.Tensor) -> torch.Tensor: + """Validate and normalize box tensor shape.""" + + if boxes.numel() == 0: + return torch.empty((0, 4), dtype=torch.float32) + if boxes.ndim != 2 or boxes.shape[1] != 4: + raise ValueError( + f"GroundingDINO model returned invalid boxes shape: {tuple(boxes.shape)}." + ) + return boxes.float().cpu() + + +def _detections_from_boxes( + boxes: torch.Tensor, + scores: torch.Tensor, + image_height: int, + image_width: int, +) -> tuple[TextBoxDetection, ...]: + """Convert XYXY boxes to clipped prompt detections.""" + + if boxes.numel() == 0: + return () + if scores.ndim != 1 or int(scores.shape[0]) != int(boxes.shape[0]): + raise ValueError( + "GroundingDINO model returned confidence scores that do not match boxes." + ) + + detections: list[TextBoxDetection] = [] + for box, score in zip(boxes, scores, strict=True): + left = max(0, min(image_width, int(round(float(box[0].item()))))) + top = max(0, min(image_height, int(round(float(box[1].item()))))) + right = max(0, min(image_width, int(round(float(box[2].item()))))) + bottom = max(0, min(image_height, int(round(float(box[3].item()))))) + if right <= left or bottom <= top: + continue + detections.append( + TextBoxDetection( + bbox=BoundingBox(left, top, right, bottom), + confidence=float(score.item()), + ) + ) + return tuple(detections) + + +def _tensor_to_pil(sample: torch.Tensor) -> Image.Image: + """Convert one ComfyUI HWC image tensor to RGB PIL image.""" + + import numpy as np + + array = sample.detach().cpu().float().clamp(0.0, 1.0).numpy() + if array.shape[-1] == 1: + array = np.repeat(array, 3, axis=-1) + if array.shape[-1] == 4: + array = array[..., :3] + return Image.fromarray((array * 255.0).round().astype(np.uint8)) + + +@contextmanager +def _managed_inference( + loaded_model: LoadedGroundingDINOModel, + execution_device: str, +) -> Iterator[LoadedManagedModel]: + """Open the managed GroundingDINO inference context.""" + + managed_model = loaded_model.managed_model + if managed_model is None: + raise TypeError("Loaded GroundingDINO model is missing device metadata.") + manager = TorchModelDeviceManager() + with manager.inference(managed_model, execution_device) as loaded: + yield loaded diff --git a/simple_syrup/runtime/tiled_sampling.py b/simple_syrup/runtime/tiled_sampling.py new file mode 100644 index 0000000..e0f17f7 --- /dev/null +++ b/simple_syrup/runtime/tiled_sampling.py @@ -0,0 +1,315 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Portions of this file incorporate behavior derived from +# multidiffusion-upscaler-for-automatic1111. See third_party/manifest.toml and +# third_party/NOTICE.md. + +"""Shared runtime helpers for tiled latent denoising samplers.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any, TypeAlias + +import torch + +from ..domain.tiled_diffusion import LatentTile, TiledDiffusionPlan + +Latent: TypeAlias = dict[str, Any] +ApplyModel: TypeAlias = Callable[..., torch.Tensor] +ModelFunctionWrapper: TypeAlias = Callable[[ApplyModel, dict[str, Any]], torch.Tensor] + +UNSUPPORTED_CONDITIONING_KEYS = frozenset({"area", "mask", "control", "gligen"}) + + +def validate_sampling_controls( + *, + steps: int, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_batch_size: int, +) -> None: + """Reject invalid KSampler and tile controls before runtime side effects.""" + + if steps < 1: + raise ValueError("steps must be at least 1.") + if not 0.0 <= denoise <= 1.0: + raise ValueError("denoise must be between 0 and 1.") + if latent_tile_width < 4: + raise ValueError("latent_tile_width must be at least 4.") + if latent_tile_height < 4: + raise ValueError("latent_tile_height must be at least 4.") + if latent_tile_batch_size < 1: + raise ValueError("latent_tile_batch_size must be at least 1.") + + +def validate_latent_samples( + latent_image: Latent, + *, + sampler_label: str, +) -> torch.Tensor: + """Return validated samples from a ComfyUI latent dictionary.""" + + samples = latent_image.get("samples") + if not isinstance(samples, torch.Tensor): + raise ValueError("latent samples must be a torch tensor.") + validate_tensor_shape(samples, sampler_label=sampler_label) + return samples + + +def validate_tensor_shape(samples: torch.Tensor, *, sampler_label: str) -> None: + """Reject unsupported latent tensor shapes before spatial tiling.""" + + if getattr(samples, "is_nested", False): + raise ValueError( + f"{sampler_label} requires non-nested latent samples shaped " + "[batch, channels, height, width] or " + "[batch, channels, 1, height, width]." + ) + if samples.ndim == 4: + return + if samples.ndim == 5 and int(samples.shape[2]) == 1: + return + if samples.ndim == 5: + raise ValueError( + f"{sampler_label} 5D latent support requires a singleton third " + "axis shaped [batch, channels, 1, height, width]." + ) + raise ValueError( + f"{sampler_label} requires latent samples shaped " + "[batch, channels, height, width] or " + "[batch, channels, 1, height, width]." + ) + + +def reject_unsupported_conditioning( + conditioning: object, + *, + sampler_label: str, +) -> None: + """Reject regional and external-control conditioning for basic tiled samplers.""" + + if contains_unsupported_conditioning_key(conditioning): + raise ValueError( + f"{sampler_label} does not support regional conditioning or " + "ControlNet in the first implementation." + ) + + +def contains_unsupported_conditioning_key(value: object) -> bool: + """Return whether a nested conditioning object contains unsupported keys.""" + + if isinstance(value, dict): + if any(key in UNSUPPORTED_CONDITIONING_KEYS for key in value): + return True + return any( + contains_unsupported_conditioning_key(item) for item in value.values() + ) + if isinstance(value, list | tuple): + return any(contains_unsupported_conditioning_key(item) for item in value) + return False + + +def spatial_tile_slicer(tile: LatentTile, tensor_ndim: int) -> tuple[slice, ...]: + """Return a slicer that crops only a tensor's final height and width axes.""" + + return ( + (slice(None),) * (tensor_ndim - 2) + + (slice(tile.y, tile.y + tile.height),) + + (slice(tile.x, tile.x + tile.width),) + ) + + +def new_spatial_weight_buffer( + x: torch.Tensor, + plan: TiledDiffusionPlan, +) -> torch.Tensor: + """Create a float32 spatial weight buffer broadcastable over non-spatial axes.""" + + return torch.zeros( + (1,) * (x.ndim - 2) + (plan.latent_height, plan.latent_width), + device=x.device, + dtype=torch.float32, + ) + + +def make_tiled_model_args( + *, + args: dict[str, Any], + tiles: Sequence[LatentTile], + input_batch_size: int, + latent_height: int, + latent_width: int, +) -> dict[str, Any]: + """Create apply-model args for one spatial tile batch.""" + + x = args["input"] + timestep = args["timestep"] + conditioning = args.get("c", {}) + if not isinstance(x, torch.Tensor): + raise ValueError("tiled sampler model input must be a tensor.") + if not isinstance(timestep, torch.Tensor): + raise ValueError("tiled sampler timestep must be a tensor.") + if not isinstance(conditioning, dict): + raise ValueError("tiled sampler conditioning must be a dict.") + + tiled_x = torch.cat( + [x[spatial_tile_slicer(tile, x.ndim)] for tile in tiles], + dim=0, + ) + tiled_timestep = torch.cat([timestep] * len(tiles), dim=0) + tiled_conditioning = tile_conditioning( + conditioning=conditioning, + tiles=tiles, + input_batch_size=input_batch_size, + latent_height=latent_height, + latent_width=latent_width, + tiled_timestep=tiled_timestep, + ) + tiled_args = args.copy() + tiled_args["input"] = tiled_x + tiled_args["timestep"] = tiled_timestep + tiled_args["c"] = tiled_conditioning + if "cond_or_uncond" in args: + tiled_args["cond_or_uncond"] = repeat_sequence( + args["cond_or_uncond"], + len(tiles), + ) + return tiled_args + + +def tile_conditioning( + *, + conditioning: dict[str, Any], + tiles: Sequence[LatentTile], + input_batch_size: int, + latent_height: int, + latent_width: int, + tiled_timestep: torch.Tensor, +) -> dict[str, Any]: + """Tile ComfyUI apply-model conditioning alongside latent tiles.""" + + tiled: dict[str, Any] = {} + for key, value in conditioning.items(): + if key == "transformer_options" and isinstance(value, dict): + tiled[key] = tile_transformer_options( + value, + tile_count=len(tiles), + tiled_timestep=tiled_timestep, + ) + continue + tiled[key] = tile_value( + value, + tiles=tiles, + input_batch_size=input_batch_size, + latent_height=latent_height, + latent_width=latent_width, + ) + return tiled + + +def tile_transformer_options( + options: dict[str, Any], + *, + tile_count: int, + tiled_timestep: torch.Tensor, +) -> dict[str, Any]: + """Repeat transformer metadata that is aligned to the model batch.""" + + tiled_options = options.copy() + if "cond_or_uncond" in options: + tiled_options["cond_or_uncond"] = repeat_sequence( + options["cond_or_uncond"], + tile_count, + ) + if "uuids" in options: + tiled_options["uuids"] = repeat_sequence(options["uuids"], tile_count) + if "sigmas" in options: + tiled_options["sigmas"] = tiled_timestep + return tiled_options + + +def tile_value( + value: Any, + *, + tiles: Sequence[LatentTile], + input_batch_size: int, + latent_height: int, + latent_width: int, +) -> Any: + """Tile tensors inside a conditioning value while preserving containers.""" + + if isinstance(value, torch.Tensor): + return tile_tensor( + value, + tiles=tiles, + input_batch_size=input_batch_size, + latent_height=latent_height, + latent_width=latent_width, + ) + if isinstance(value, list): + return [ + tile_value( + item, + tiles=tiles, + input_batch_size=input_batch_size, + latent_height=latent_height, + latent_width=latent_width, + ) + for item in value + ] + if isinstance(value, tuple): + return tuple( + tile_value( + item, + tiles=tiles, + input_batch_size=input_batch_size, + latent_height=latent_height, + latent_width=latent_width, + ) + for item in value + ) + return value + + +def tile_tensor( + tensor: torch.Tensor, + *, + tiles: Sequence[LatentTile], + input_batch_size: int, + latent_height: int, + latent_width: int, +) -> torch.Tensor: + """Repeat or crop a conditioning tensor for a tile batch.""" + + if tensor.ndim >= 4 and tensor.shape[-2:] == (latent_height, latent_width): + return torch.cat( + [ + tensor[ + ..., + tile.y : tile.y + tile.height, + tile.x : tile.x + tile.width, + ] + for tile in tiles + ], + dim=0, + ) + if tensor.ndim >= 1 and tensor.shape[0] == input_batch_size: + return torch.cat([tensor] * len(tiles), dim=0) + if tensor.ndim >= 1 and tensor.shape[0] == 1: + repeat_shape = [input_batch_size * len(tiles)] + [1] * (tensor.ndim - 1) + return tensor.repeat(repeat_shape) + return tensor + + +def repeat_sequence(value: object, times: int) -> object: + """Repeat a metadata sequence once per tile.""" + + if isinstance(value, list): + return value * times + if isinstance(value, tuple): + return value * times + return value diff --git a/simple_syrup/runtime/torchlanc_resampler.py b/simple_syrup/runtime/torchlanc_resampler.py new file mode 100644 index 0000000..8f6efbb --- /dev/null +++ b/simple_syrup/runtime/torchlanc_resampler.py @@ -0,0 +1,92 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""TorchLanc runtime adapter for GPU Lanczos resizing.""" + +from __future__ import annotations + +import importlib +from collections.abc import Callable +from types import ModuleType +from typing import cast + +import torch + +from .image_resamplers import DeviceProvider + +TorchLancResize = Callable[..., torch.Tensor] +CudaAvailable = Callable[[], bool] + + +class TorchLanczosResampler: + """Resize images with TorchLanc's GPU Lanczos implementation.""" + + def __init__( + self, + lanczos_resize: TorchLancResize | None = None, + cuda_available: CudaAvailable | None = None, + device_provider: DeviceProvider | None = None, + ) -> None: + """Create a TorchLanc adapter with injectable runtime boundaries.""" + + self._lanczos_resize = lanczos_resize + self._cuda_available = cuda_available or torch.cuda.is_available + self._device_provider = device_provider + + def resize( + self, + samples: torch.Tensor, + width: int, + height: int, + sinc_window: int, + precision: str, + ) -> torch.Tensor: + """Resize BCHW samples with TorchLanc on the configured torch device.""" + + if not self._cuda_available(): + raise RuntimeError( + "GPU Lanczos resizing requires a CUDA device. Select " + "processor='cpu' for Pillow Lanczos." + ) + + device = self._get_torch_device() + samples_on_device = samples.to(device=device) + return self._get_lanczos_resize()( + samples_on_device, + height=int(height), + width=int(width), + a=int(sinc_window), + precision=str(precision), + clamp=True, + chunk_size=0, + ) + + def _get_lanczos_resize(self) -> TorchLancResize: + """Return TorchLanc's resize function, importing it lazily when needed.""" + + if self._lanczos_resize is not None: + return self._lanczos_resize + + try: + module: ModuleType = importlib.import_module("torchlanc") + except ModuleNotFoundError as exc: + raise RuntimeError( + "TorchLanc is required for GPU Lanczos resizing. Install this " + "node pack's requirements into the ComfyUI virtual environment." + ) from exc + + resize = module.lanczos_resize + if not callable(resize): + raise RuntimeError("TorchLanc does not expose callable lanczos_resize.") + return cast(TorchLancResize, resize) + + def _get_torch_device(self) -> torch.device: + """Return ComfyUI's configured torch execution device.""" + + if self._device_provider is not None: + return torch.device(self._device_provider()) + + from comfy import model_management + + return torch.device(model_management.get_torch_device()) diff --git a/simple_syrup/runtime/ultralytics_detection.py b/simple_syrup/runtime/ultralytics_detection.py new file mode 100644 index 0000000..8e7ab37 --- /dev/null +++ b/simple_syrup/runtime/ultralytics_detection.py @@ -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 + +"""Ultralytics inference result parsing for SimpleSyrup detection services.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import torch + +from ..domain.segs import BoundingBox +from ..masking.segs_mask_ops import normalize_mask +from .ultralytics_loader import UltralyticsDetectorModel + + +@dataclass(frozen=True) +class UltralyticsDetection: + """Represent one parsed Ultralytics prediction.""" + + bbox: BoundingBox + confidence: float + label: str + mask: torch.Tensor | None + + +def run_ultralytics_detection( + detector_model: UltralyticsDetectorModel, + image: torch.Tensor, + threshold: float, + prefer_segmentation: bool, +) -> tuple[UltralyticsDetection, ...]: + """Run Ultralytics inference and parse detections into native values.""" + + if not 0.0 <= threshold <= 1.0: + raise ValueError("threshold must be between 0 and 1.") + + raw_results = detector_model.model( + _image_to_pil(image), + conf=threshold, + verbose=False, + ) + result = _first_result(raw_results) + if result is None: + return () + return parse_ultralytics_result( + result=result, + image_height=int(image.shape[1]), + image_width=int(image.shape[2]), + class_names=detector_model.names, + prefer_segmentation=prefer_segmentation, + ) + + +def parse_ultralytics_result( + result: object, + image_height: int, + image_width: int, + class_names: dict[int, str], + prefer_segmentation: bool, +) -> tuple[UltralyticsDetection, ...]: + """Parse one Ultralytics result object without depending on its concrete type.""" + + boxes_object = getattr(result, "boxes", None) + if boxes_object is None: + return () + + boxes = _tensor_attr(boxes_object, "xyxy") + if boxes.numel() == 0: + return () + confidences = _tensor_attr(boxes_object, "conf") + classes = _tensor_attr(boxes_object, "cls") + masks = ( + _result_masks(result, image_height, image_width) if prefer_segmentation else [] + ) + + names = _result_names(result, class_names) + detections: list[UltralyticsDetection] = [] + for index, raw_box in enumerate(boxes): + bbox = _bbox_from_tensor(raw_box, image_height, image_width) + confidence = ( + float(confidences[index].item()) if confidences.numel() > index else 1.0 + ) + class_index = int(classes[index].item()) if classes.numel() > index else -1 + detections.append( + UltralyticsDetection( + bbox=bbox, + confidence=confidence, + label=names.get(class_index, str(class_index)), + mask=masks[index] if index < len(masks) else None, + ) + ) + return tuple(detections) + + +def _image_to_pil(image: torch.Tensor) -> object: + """Convert a single ComfyUI BHWC image tensor to a PIL image.""" + + try: + from PIL import Image + except ModuleNotFoundError as exc: + raise RuntimeError( + "Ultralytics detection requires Pillow in the ComfyUI venv." + ) from exc + + array = image[0, :, :, :3].detach().cpu().clamp(0.0, 1.0).mul(255.0).byte().numpy() + return Image.fromarray(array, mode="RGB") + + +def _first_result(raw_results: object) -> object | None: + """Return the first Ultralytics result from a result container.""" + + if isinstance(raw_results, Sequence) and not isinstance(raw_results, (str, bytes)): + return raw_results[0] if raw_results else None + return raw_results + + +def _tensor_attr(source: object, name: str) -> torch.Tensor: + """Read a tensor-like result attribute as a CPU tensor.""" + + value = getattr(source, name, None) + if value is None: + return torch.empty((0,), dtype=torch.float32) + return torch.as_tensor(value).detach().cpu() + + +def _result_masks( + result: object, + image_height: int, + image_width: int, +) -> list[torch.Tensor]: + """Return normalized segmentation masks from a result object.""" + + masks_object = getattr(result, "masks", None) + if masks_object is None: + return [] + data = getattr(masks_object, "data", None) + if data is None: + return [] + tensor = torch.as_tensor(data).detach().cpu().float() + if tensor.ndim == 2: + tensor = tensor.unsqueeze(0) + if tensor.ndim != 3: + raise ValueError("Ultralytics masks must be shaped as BHW.") + return [normalize_mask(mask, image_height, image_width) for mask in tensor] + + +def _result_names(result: object, fallback: dict[int, str]) -> dict[int, str]: + """Return class names from the result object or detector model metadata.""" + + names = getattr(result, "names", None) + if isinstance(names, dict): + return {int(key): str(value) for key, value in names.items()} + if isinstance(names, list): + return {index: str(value) for index, value in enumerate(names)} + return fallback + + +def _bbox_from_tensor( + raw_box: torch.Tensor, + image_height: int, + image_width: int, +) -> BoundingBox: + """Clamp one xyxy tensor into a valid integer bounding box.""" + + values = raw_box.tolist() + if len(values) != 4: + raise ValueError("Ultralytics bbox must contain four xyxy coordinates.") + left = max(0, min(image_width - 1, int(round(float(values[0]))))) + top = max(0, min(image_height - 1, int(round(float(values[1]))))) + right = max(left + 1, min(image_width, int(round(float(values[2]))))) + bottom = max(top + 1, min(image_height, int(round(float(values[3]))))) + return BoundingBox(left, top, right, bottom) diff --git a/simple_syrup/runtime/ultralytics_loader.py b/simple_syrup/runtime/ultralytics_loader.py new file mode 100644 index 0000000..3d8f3e2 --- /dev/null +++ b/simple_syrup/runtime/ultralytics_loader.py @@ -0,0 +1,426 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Ultralytics detector model discovery and lazy loading.""" + +from __future__ import annotations + +import importlib +from collections.abc import MutableMapping +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType +from typing import Any, TypeAlias, cast + +from ..shared.logging import get_logger +from .model_folders import SUPPORTED_MODEL_EXTENSIONS +from .model_instance_cache import ModelInstanceCache + +LOGGER = get_logger(__name__) + +NO_LOCAL_ULTRALYTICS_MODELS = "No local Ultralytics models found" +ULTRALYTICS_FOLDER = "ultralytics" +ULTRALYTICS_BBOX_FOLDER = "ultralytics_bbox" +ULTRALYTICS_SEGM_FOLDER = "ultralytics_segm" + +ModelFolderRegistry: TypeAlias = dict[str, tuple[list[str], set[str]]] + + +@dataclass(frozen=True) +class UltralyticsDetectorModel: + """Store a loaded Ultralytics detector with SimpleSyrup metadata.""" + + model_name: str + model_path: Path + model: Any + task: str + names: dict[int, str] + supports_segmentation: bool + + +@dataclass(frozen=True) +class LoadedUltralyticsDetector: + """Bundle native and compatibility detector outputs from the loader.""" + + detector_model: UltralyticsDetectorModel + bbox_detector: object + segm_detector: object + + +@dataclass(frozen=True) +class UltralyticsModelCacheKey: + """Identify a loaded Ultralytics detector for process-level reuse.""" + + model_name: str + model_path: Path + + +_LOADED_ULTRALYTICS_MODELS: dict[ + UltralyticsModelCacheKey, LoadedUltralyticsDetector +] = {} + + +class UltralyticsLoaderService: + """Discover and load Ultralytics detector models from ComfyUI folders.""" + + def __init__( + self, + folder_paths_module: ModuleType | None = None, + ultralytics_module: ModuleType | None = None, + cache: ( + MutableMapping[UltralyticsModelCacheKey, LoadedUltralyticsDetector] | None + ) = None, + ) -> None: + """Create the loader with injectable runtime modules for tests.""" + + self._folder_paths_module = folder_paths_module + self._ultralytics_module = ultralytics_module + self._cache: ModelInstanceCache[ + UltralyticsModelCacheKey, LoadedUltralyticsDetector + ] = ModelInstanceCache( + cache if cache is not None else _LOADED_ULTRALYTICS_MODELS + ) + + def model_choices(self) -> list[str]: + """Return local Ultralytics model choices for ComfyUI dropdowns.""" + + choices = self.available_models() + return choices or [NO_LOCAL_ULTRALYTICS_MODELS] + + def available_models(self) -> list[str]: + """Return supported model files in registered Ultralytics folders.""" + + self._register_model_folders() + folder_paths = self._folder_paths() + choices: set[str] = set() + + for folder in self._folder_paths_for(ULTRALYTICS_FOLDER): + if not folder.is_dir(): + continue + choices.update(path.name for path in _supported_files(folder)) + bbox_dir = folder / "bbox" + segm_dir = folder / "segm" + choices.update(f"bbox/{path.name}" for path in _supported_files(bbox_dir)) + choices.update(f"segm/{path.name}" for path in _supported_files(segm_dir)) + + for path in self._folder_paths_for(ULTRALYTICS_BBOX_FOLDER): + choices.update(f"bbox/{file.name}" for file in _supported_files(path)) + for path in self._folder_paths_for(ULTRALYTICS_SEGM_FOLDER): + choices.update(f"segm/{file.name}" for file in _supported_files(path)) + + registry = cast( + ModelFolderRegistry, + getattr(folder_paths, "folder_names_and_paths", {}), + ) + for folder_name in ( + ULTRALYTICS_FOLDER, + ULTRALYTICS_BBOX_FOLDER, + ULTRALYTICS_SEGM_FOLDER, + ): + if folder_name not in registry: + continue + for filename in folder_paths.get_filename_list(folder_name): + path = Path(str(filename)) + if path.suffix.lower() not in SUPPORTED_MODEL_EXTENSIONS: + continue + if folder_name == ULTRALYTICS_BBOX_FOLDER: + choices.add(f"bbox/{path.name}") + elif folder_name == ULTRALYTICS_SEGM_FOLDER: + choices.add(f"segm/{path.name}") + else: + choices.add(path.as_posix()) + + return sorted(choices) + + def load(self, model_name: str) -> LoadedUltralyticsDetector: + """Load one Ultralytics model and create compatibility facades.""" + + self.reject_sentinel(model_name) + model_path = self.resolve_model_path(model_name) + normalized_name = _normalized_model_name(model_name) + key = UltralyticsModelCacheKey( + model_name=normalized_name, + model_path=model_path.resolve(), + ) + already_loaded = key in self._cache.entries + loaded = self._cache.get_or_load( + key, + lambda: self._load_uncached_detector(normalized_name, model_path), + ) + if already_loaded: + LOGGER.info( + "Ultralytics model loaded from process cache", + extra={ + "operation": "load_ultralytics_model", + "model_name": normalized_name, + "model_path": str(model_path), + "task": loaded.detector_model.task, + }, + ) + return loaded + + def _load_uncached_detector( + self, + model_name: str, + model_path: Path, + ) -> LoadedUltralyticsDetector: + """Load an Ultralytics detector after path resolution and cache lookup.""" + + ultralytics_module = self._ultralytics() + model_class = getattr(ultralytics_module, "YOLO", None) + if model_class is None: + raise RuntimeError( + "Ultralytics support requires a module exposing the YOLO class." + ) + + try: + raw_model = model_class(str(model_path)) + except Exception as exc: + LOGGER.error( + "Failed to load Ultralytics model", + extra={ + "operation": "load_ultralytics_model", + "model_name": model_name, + "model_path": str(model_path), + }, + exc_info=True, + ) + raise RuntimeError( + f"Ultralytics model '{model_name}' could not be loaded from " + f"'{model_path}'." + ) from exc + + task = _model_task(model_name, raw_model) + device_hint = _model_device_hint(raw_model) + detector_model = UltralyticsDetectorModel( + model_name=model_name, + model_path=model_path, + model=raw_model, + task=task, + names=_model_names(raw_model), + supports_segmentation=task in {"segment", "segm"}, + ) + + from .detector_compat import BBoxDetectorFacade, SegmDetectorFacade + + bbox_detector = BBoxDetectorFacade(detector_model) + segm_detector = SegmDetectorFacade(detector_model, bbox_detector) + LOGGER.info( + "Ultralytics model loaded", + extra={ + "operation": "load_ultralytics_model", + "model_name": model_name, + "model_path": str(model_path), + "task": task, + "device": device_hint, + }, + ) + return LoadedUltralyticsDetector( + detector_model=detector_model, + bbox_detector=bbox_detector, + segm_detector=segm_detector, + ) + + def reject_sentinel(self, model_name: str) -> None: + """Reject placeholder dropdown selections before filesystem work.""" + + if model_name == NO_LOCAL_ULTRALYTICS_MODELS: + raise ValueError( + "No local Ultralytics models are available. Install a model in " + "models\\ultralytics, models\\ultralytics\\bbox, or " + "models\\ultralytics\\segm." + ) + + def resolve_model_path(self, model_name: str) -> Path: + """Resolve a safe model choice to a file inside ComfyUI model folders.""" + + safe_name = Path(model_name.replace("\\", "/")) + if safe_name.is_absolute() or ".." in safe_name.parts: + raise ValueError( + f"Ultralytics model name '{model_name}' is not a safe relative path." + ) + + candidates = self._candidate_paths(safe_name) + for candidate in candidates: + if candidate.is_file(): + return candidate + + raise ValueError( + f"Ultralytics model '{model_name}' was not found in configured " + "ComfyUI model folders." + ) + + def _candidate_paths(self, model_name: Path) -> list[Path]: + """Return bounded filesystem candidates for a model choice.""" + + self._register_model_folders() + candidates: list[Path] = [] + parts = model_name.parts + if len(parts) >= 2 and parts[0] == "bbox": + relative = Path(*parts[1:]) + candidates.extend( + folder / relative + for folder in self._folder_paths_for(ULTRALYTICS_BBOX_FOLDER) + ) + candidates.extend( + folder / "bbox" / relative + for folder in self._folder_paths_for(ULTRALYTICS_FOLDER) + ) + elif len(parts) >= 2 and parts[0] == "segm": + relative = Path(*parts[1:]) + candidates.extend( + folder / relative + for folder in self._folder_paths_for(ULTRALYTICS_SEGM_FOLDER) + ) + candidates.extend( + folder / "segm" / relative + for folder in self._folder_paths_for(ULTRALYTICS_FOLDER) + ) + else: + candidates.extend( + folder / model_name + for folder in self._folder_paths_for(ULTRALYTICS_FOLDER) + ) + return candidates + + def _register_model_folders(self) -> None: + """Register conventional Ultralytics folders with ComfyUI when possible.""" + + folder_paths = self._folder_paths() + models_dir = Path(str(folder_paths.models_dir)) + add_model_folder_path = getattr(folder_paths, "add_model_folder_path", None) + if add_model_folder_path is None: + return + + registry = cast( + ModelFolderRegistry, + getattr(folder_paths, "folder_names_and_paths", {}), + ) + registrations = ( + (ULTRALYTICS_FOLDER, models_dir / "ultralytics"), + (ULTRALYTICS_BBOX_FOLDER, models_dir / "ultralytics" / "bbox"), + (ULTRALYTICS_SEGM_FOLDER, models_dir / "ultralytics" / "segm"), + ) + for folder_name, path in registrations: + if folder_name in registry: + continue + add_model_folder_path(folder_name, str(path)) + + def _folder_paths_for(self, folder_name: str) -> list[Path]: + """Return registered paths for one ComfyUI model folder.""" + + folder_paths = self._folder_paths() + models_dir = Path(str(folder_paths.models_dir)) + fallback = { + ULTRALYTICS_FOLDER: models_dir / "ultralytics", + ULTRALYTICS_BBOX_FOLDER: models_dir / "ultralytics" / "bbox", + ULTRALYTICS_SEGM_FOLDER: models_dir / "ultralytics" / "segm", + }[folder_name] + registry = cast( + ModelFolderRegistry, + getattr(folder_paths, "folder_names_and_paths", {}), + ) + paths = [fallback] + if folder_name in registry: + paths = [Path(str(path)) for path in registry[folder_name][0]] + paths + return _unique_paths(paths) + + def _folder_paths(self) -> ModuleType: + """Import ComfyUI folder path helpers lazily.""" + + if self._folder_paths_module is not None: + return self._folder_paths_module + module = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + self._folder_paths_module = module + return module + + def _ultralytics(self) -> ModuleType: + """Import Ultralytics lazily and fail with an actionable message.""" + + if self._ultralytics_module is not None: + return self._ultralytics_module + try: + module = importlib.import_module("ultralytics") + except ModuleNotFoundError as exc: + raise RuntimeError( + "Ultralytics support requires the 'ultralytics' package in the " + "ComfyUI virtual environment." + ) from exc + if not isinstance(module, ModuleType): + raise TypeError("ultralytics import did not return a module.") + self._ultralytics_module = module + return module + + +def _supported_files(folder: Path) -> list[Path]: + """Return directly contained supported model files for a folder.""" + + if not folder.is_dir(): + return [] + return sorted( + path + for path in folder.iterdir() + if path.is_file() and path.suffix.lower() in SUPPORTED_MODEL_EXTENSIONS + ) + + +def _unique_paths(paths: list[Path]) -> list[Path]: + """Return unique paths while preserving order.""" + + unique: list[Path] = [] + seen: set[str] = set() + for path in paths: + key = str(path) + if key in seen: + continue + unique.append(path) + seen.add(key) + return unique + + +def _normalized_model_name(model_name: str) -> str: + """Return a stable model selection string for cache identity.""" + + return model_name.replace("\\", "/") + + +def _model_task(model_name: str, raw_model: object) -> str: + """Infer detector task from choice prefix or model metadata.""" + + normalized_name = model_name.replace("\\", "/") + if normalized_name.startswith("segm/"): + return "segment" + if normalized_name.startswith("bbox/"): + return "detect" + + task = getattr(raw_model, "task", None) + if isinstance(task, str) and task: + return task + return "detect" + + +def _model_names(raw_model: object) -> dict[int, str]: + """Extract class names from a loaded Ultralytics model.""" + + names = getattr(raw_model, "names", {}) + if isinstance(names, dict): + return {int(key): str(value) for key, value in names.items()} + if isinstance(names, list): + return {index: str(value) for index, value in enumerate(names)} + return {} + + +def _model_device_hint(raw_model: object) -> str: + """Return a best-effort Ultralytics device hint for diagnostics.""" + + direct_device = getattr(raw_model, "device", None) + if direct_device is not None: + return str(direct_device) + inner_model = getattr(raw_model, "model", None) + inner_device = getattr(inner_model, "device", None) + if inner_device is not None: + return str(inner_device) + return "runtime-owned" diff --git a/simple_syrup/runtime/vae_loader.py b/simple_syrup/runtime/vae_loader.py new file mode 100644 index 0000000..23fde5a --- /dev/null +++ b/simple_syrup/runtime/vae_loader.py @@ -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 + +"""Load ComfyUI VAE selections using ComfyUI's VAE loader policy.""" + +from __future__ import annotations + +import importlib +import os +from pathlib import Path +from types import ModuleType +from typing import Any + +import torch + +VIDEO_TAES = ("taehv", "lighttaew2_2", "lighttaew2_1", "lighttaehy1_5", "taeltx_2") +IMAGE_TAES = ("taesd", "taesdxl", "taesd3", "taef1") + + +class VaeLoaderService: + """Load VAE choices through ComfyUI-compatible selection rules.""" + + def __init__(self, folder_paths_module: ModuleType | None = None) -> None: + """Create a VAE loader with injectable ComfyUI folder paths.""" + + self._folder_paths_module = folder_paths_module + + def load_vae(self, vae_name: str) -> object: + """Load and validate the selected VAE.""" + + if vae_name == "pixel_space": + sd: dict[str, object] = {"pixel_space_vae": torch.tensor(1.0)} + metadata = None + elif vae_name in IMAGE_TAES: + sd = _load_taesd(vae_name, self._folder_paths()) + metadata = None + else: + folder_name = ( + "vae_approx" if os.path.splitext(vae_name)[0] in VIDEO_TAES else "vae" + ) + vae_path = self._folder_paths().get_full_path_or_raise( + folder_name, + vae_name, + ) + return load_vae_path(Path(str(vae_path))) + + return _build_vae(sd, metadata) + + def _folder_paths(self) -> ModuleType: + """Return the ComfyUI folder_paths module.""" + + if self._folder_paths_module is not None: + return self._folder_paths_module + module = _folder_paths() + self._folder_paths_module = module + return module + + +def vae_choices(folder_paths_module: ModuleType | None = None) -> list[str]: + """Return VAE choices matching ComfyUI's `VAELoader` list policy.""" + + folder_paths = folder_paths_module or _folder_paths() + vaes: list[str] = list(folder_paths.get_filename_list("vae")) + approx_vaes: list[str] = list(folder_paths.get_filename_list("vae_approx")) + sdxl_taesd_enc = False + sdxl_taesd_dec = False + sd1_taesd_enc = False + sd1_taesd_dec = False + sd3_taesd_enc = False + sd3_taesd_dec = False + f1_taesd_enc = False + f1_taesd_dec = False + + for vae in approx_vaes: + if vae.startswith("taesd_decoder."): + sd1_taesd_dec = True + elif vae.startswith("taesd_encoder."): + sd1_taesd_enc = True + elif vae.startswith("taesdxl_decoder."): + sdxl_taesd_dec = True + elif vae.startswith("taesdxl_encoder."): + sdxl_taesd_enc = True + elif vae.startswith("taesd3_decoder."): + sd3_taesd_dec = True + elif vae.startswith("taesd3_encoder."): + sd3_taesd_enc = True + elif vae.startswith("taef1_encoder."): + f1_taesd_dec = True + elif vae.startswith("taef1_decoder."): + f1_taesd_enc = True + else: + for tae in VIDEO_TAES: + if vae.startswith(tae): + vaes.append(vae) + + if sd1_taesd_dec and sd1_taesd_enc: + vaes.append("taesd") + if sdxl_taesd_dec and sdxl_taesd_enc: + vaes.append("taesdxl") + if sd3_taesd_dec and sd3_taesd_enc: + vaes.append("taesd3") + if f1_taesd_dec and f1_taesd_enc: + vaes.append("taef1") + vaes.append("pixel_space") + return vaes + + +def load_vae_path(path: Path) -> object: + """Load a conventional VAE file from an absolute path.""" + + comfy_utils = importlib.import_module("comfy.utils") + sd, metadata = comfy_utils.load_torch_file(str(path), return_metadata=True) + return _build_vae(sd, metadata) + + +def _load_taesd(name: str, folder_paths: ModuleType) -> dict[str, object]: + """Load a TAESD encoder and decoder pair using ComfyUI's naming policy.""" + + sd: dict[str, object] = {} + approx_vaes: list[str] = list(folder_paths.get_filename_list("vae_approx")) + encoder = next(vae for vae in approx_vaes if vae.startswith(f"{name}_encoder.")) + decoder = next(vae for vae in approx_vaes if vae.startswith(f"{name}_decoder.")) + comfy_utils = importlib.import_module("comfy.utils") + + enc = comfy_utils.load_torch_file( + folder_paths.get_full_path_or_raise("vae_approx", encoder) + ) + for key, value in enc.items(): + sd[f"taesd_encoder.{key}"] = value + + dec = comfy_utils.load_torch_file( + folder_paths.get_full_path_or_raise("vae_approx", decoder) + ) + for key, value in dec.items(): + sd[f"taesd_decoder.{key}"] = value + + if name == "taesd": + sd["vae_scale"] = torch.tensor(0.18215) + sd["vae_shift"] = torch.tensor(0.0) + elif name == "taesdxl": + sd["vae_scale"] = torch.tensor(0.13025) + sd["vae_shift"] = torch.tensor(0.0) + elif name == "taesd3": + sd["vae_scale"] = torch.tensor(1.5305) + sd["vae_shift"] = torch.tensor(0.0609) + elif name == "taef1": + sd["vae_scale"] = torch.tensor(0.3611) + sd["vae_shift"] = torch.tensor(0.1159) + return sd + + +def _build_vae(sd: dict[str, object], metadata: object | None) -> object: + """Instantiate and validate a ComfyUI VAE object.""" + + comfy_sd = _comfy_sd() + vae = comfy_sd.VAE(sd=sd, metadata=metadata) + vae.throw_exception_if_invalid() + return vae + + +def _folder_paths() -> ModuleType: + """Import ComfyUI folder paths lazily.""" + + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + return module + + +def _comfy_sd() -> Any: + """Import ComfyUI's stable diffusion loading module lazily.""" + + return importlib.import_module("comfy.sd") diff --git a/simple_syrup/runtime/vitmatte_loader.py b/simple_syrup/runtime/vitmatte_loader.py new file mode 100644 index 0000000..cdebf6e --- /dev/null +++ b/simple_syrup/runtime/vitmatte_loader.py @@ -0,0 +1,272 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Resolve, download, and load ViTMatte models.""" + +from __future__ import annotations + +import importlib +from collections.abc import MutableMapping +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType +from typing import Any, Protocol + +from ..shared.logging import get_logger +from .loaded_models import LoadedViTMatteModel +from .model_catalog import ModelEntry, get_vitmatte_entry, vitmatte_choices +from .model_device_manager import TorchModelDeviceManager +from .model_downloads import NullProgressReporter, ProgressReporter +from .model_folders import get_primary_model_folder, register_required_model_folders +from .model_instance_cache import ModelInstanceCache + +LOGGER = get_logger(__name__) +VITMATTE_CHOICES = tuple(vitmatte_choices()) +VITMATTE_SMALL = "vitmatte-small-composition-1k" +VITMATTE_BASE = "vitmatte-base-composition-1k" + + +class SnapshotDownloader(Protocol): + """Download a Hugging Face snapshot into a local directory.""" + + def download_snapshot( + self, + repo_id: str, + destination: Path, + progress: ProgressReporter | None = None, + ) -> Path: + """Download a model snapshot and return its local directory.""" + + +@dataclass(frozen=True) +class ViTMatteResolution: + """Resolved ViTMatte directory and source metadata.""" + + path: Path + source: str + downloaded: bool + + +@dataclass(frozen=True) +class ViTMatteModelCacheKey: + """Identify a loaded ViTMatte runtime for process-level reuse.""" + + model_id: str + model_path: Path + + +_LOADED_VITMATTE_MODELS: dict[ViTMatteModelCacheKey, LoadedViTMatteModel] = {} + + +class HuggingFaceSnapshotDownloader: + """Download trusted Hugging Face model snapshots.""" + + def download_snapshot( + self, + repo_id: str, + destination: Path, + progress: ProgressReporter | None = None, + ) -> Path: + """Download a Hugging Face snapshot into `destination`.""" + + reporter = progress or NullProgressReporter() + destination.mkdir(parents=True, exist_ok=True) + reporter.start(f"Downloading {repo_id}", None) + try: + huggingface_hub = importlib.import_module("huggingface_hub") + snapshot_download = huggingface_hub.snapshot_download + snapshot_download( + repo_id=repo_id, + local_dir=str(destination), + ignore_patterns=["*.md", "*.txt", "onnx", ".git"], + ) + reporter.finish() + return destination + except Exception: + LOGGER.exception( + "vitmatte snapshot download failed", + extra={"repo_id": repo_id, "destination": str(destination)}, + ) + raise + + +class ViTMatteLoaderService: + """Resolve, optionally download, and load known ViTMatte models.""" + + def __init__( + self, + downloader: SnapshotDownloader | None = None, + folder_paths_module: ModuleType | None = None, + device_manager: TorchModelDeviceManager | None = None, + cache: ( + MutableMapping[ViTMatteModelCacheKey, LoadedViTMatteModel] | None + ) = None, + ) -> None: + """Create a ViTMatte loader with injectable external boundaries.""" + + self._downloader = downloader or HuggingFaceSnapshotDownloader() + self._folder_paths_module = folder_paths_module + self._device_manager = device_manager or TorchModelDeviceManager() + self._cache: ModelInstanceCache[ViTMatteModelCacheKey, LoadedViTMatteModel] = ( + ModelInstanceCache(cache if cache is not None else _LOADED_VITMATTE_MODELS) + ) + + def load_model( + self, + vitmatte_model: str, + auto_download: bool, + progress: ProgressReporter | None = None, + ) -> LoadedViTMatteModel: + """Load a ViTMatte model and processor without moving it to CUDA.""" + + register_required_model_folders(self._folder_paths_module) + entry = get_vitmatte_entry(vitmatte_model) + resolution = self.resolve_model_directory(entry, auto_download, progress) + key = ViTMatteModelCacheKey( + model_id=entry.entry_id, + model_path=resolution.path.resolve(), + ) + already_loaded = key in self._cache.entries + loaded = self._cache.get_or_load( + key, + lambda: self._load_uncached_model(entry, resolution), + ) + if already_loaded: + LOGGER.info( + "ViTMatte model loaded from process cache", + extra={ + "operation": "vitmatte_loader", + "model": entry.entry_id, + "model_path": str(resolution.path), + "source": resolution.source, + }, + ) + return loaded + + def _load_uncached_model( + self, + entry: ModelEntry, + resolution: ViTMatteResolution, + ) -> LoadedViTMatteModel: + """Load and wrap ViTMatte after directory resolution and cache lookup.""" + + model, processor = self._load_transformers_model(resolution.path) + managed_model = self._device_manager.manage( + model, + model_id=entry.entry_id, + source=str(resolution.path), + ) + loaded = LoadedViTMatteModel( + model=model, + processor=processor, + source=resolution.source, + model_id=entry.entry_id, + model_path=resolution.path, + managed_model=managed_model, + ) + LOGGER.info( + "ViTMatte model loaded", + extra={ + "operation": "vitmatte_loader", + "model": entry.entry_id, + "model_path": str(resolution.path), + "source": resolution.source, + }, + ) + return loaded + + def resolve_model_directory( + self, + entry: ModelEntry, + auto_download: bool, + progress: ProgressReporter | None = None, + ) -> ViTMatteResolution: + """Resolve a valid ViTMatte directory or download to the canonical path.""" + + canonical = self._canonical_path(entry) + if is_valid_vitmatte_directory(canonical): + return ViTMatteResolution(canonical, "canonical", downloaded=False) + + layerstyle = self._layerstyle_path(entry) + if layerstyle is not None and is_valid_vitmatte_directory(layerstyle): + return ViTMatteResolution(layerstyle, "layerstyle-compatible", False) + + if not auto_download: + layerstyle_hint = f" or compatible LayerStyle path: {layerstyle}" + raise FileNotFoundError( + f"ViTMatte model '{entry.display_name}' is missing and " + f"auto_download is disabled. Expected a valid model at: " + f"{canonical}{layerstyle_hint}. Enable auto_download on " + "ViTMatte Model Loader or install the model files." + ) + + downloaded = self._downloader.download_snapshot( + entry.source_repo, + canonical, + progress, + ) + if not is_valid_vitmatte_directory(downloaded): + raise FileNotFoundError( + f"Downloaded ViTMatte files in '{downloaded}' are incomplete." + ) + return ViTMatteResolution(downloaded, f"downloaded: {entry.source_repo}", True) + + def _canonical_path(self, entry: ModelEntry) -> Path: + """Return SimpleSyrup's canonical ViTMatte model directory.""" + + return ( + get_primary_model_folder("vitmatte", self._folder_paths_module) + / entry.entry_id + ) + + def _layerstyle_path(self, entry: ModelEntry) -> Path | None: + """Return LayerStyle's compatible directory for a ViTMatte entry.""" + + models_dir = self._models_dir() + if entry.entry_id == VITMATTE_SMALL: + return models_dir / "vitmatte" + if entry.entry_id == VITMATTE_BASE: + return models_dir / "vitmatte-base-composition-1k" + return None + + def _models_dir(self) -> Path: + """Return ComfyUI's models directory.""" + + folder_paths = self._folder_paths_module or importlib.import_module( + "folder_paths" + ) + return Path(str(folder_paths.models_dir)) + + def _load_transformers_model(self, path: Path) -> tuple[object, object]: + """Load ViTMatte model and processor from a local directory.""" + + try: + transformers = importlib.import_module("transformers") + except ImportError as error: + raise RuntimeError( + "transformers with ViTMatte support is required to load ViTMatte." + ) from error + + model_class: Any = transformers.VitMatteForImageMatting + processor_class: Any = transformers.VitMatteImageProcessor + model = model_class.from_pretrained(str(path), local_files_only=True) + processor = processor_class.from_pretrained(str(path), local_files_only=True) + eval_method = getattr(model, "eval", None) + if callable(eval_method): + eval_method() + return model, processor + + +def is_valid_vitmatte_directory(path: Path) -> bool: + """Return whether `path` contains a usable ViTMatte snapshot.""" + + return ( + path.is_dir() + and (path / "config.json").is_file() + and (path / "preprocessor_config.json").is_file() + and ( + (path / "model.safetensors").is_file() + or (path / "pytorch_model.bin").is_file() + ) + ) diff --git a/simple_syrup/runtime/vitmatte_refiner.py b/simple_syrup/runtime/vitmatte_refiner.py new file mode 100644 index 0000000..9460faf --- /dev/null +++ b/simple_syrup/runtime/vitmatte_refiner.py @@ -0,0 +1,287 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ViTMatte-based mask edge refinement.""" + +from __future__ import annotations + +from typing import Any, Protocol, cast + +import torch +import torch.nn.functional as F +from PIL import Image + +from .loaded_models import LoadedViTMatteModel, unwrap_vitmatte_model +from .model_device_manager import TorchModelDeviceManager, external_model_inference + + +class MaskRefinementSettings(Protocol): + """Settings required by ViTMatte refinement.""" + + @property + def detail_method(self) -> str: + """Return the selected detail method.""" + ... + + @property + def detail_erode(self) -> int: + """Return trimap erosion radius.""" + ... + + @property + def detail_dilate(self) -> int: + """Return trimap dilation radius.""" + ... + + @property + def black_point(self) -> float: + """Return mask black point.""" + ... + + @property + def white_point(self) -> float: + """Return mask white point.""" + ... + + @property + def process_detail(self) -> bool: + """Return whether detail processing is enabled.""" + ... + + @property + def execution_device(self) -> str: + """Return execution device policy.""" + ... + + @property + def max_size_pixels(self) -> int: + """Return maximum refinement pixel count.""" + ... + + +class MaskDetailRefiner(Protocol): + """Refine prompt masks with optional external detail models.""" + + def refine( + self, + image: torch.Tensor, + mask: torch.Tensor, + settings: MaskRefinementSettings, + vitmatte_model: object | None, + ) -> torch.Tensor: + """Return a refined BHW mask.""" + + +class ViTMatteRefiner: + """Refine mask edges with a connected ViTMatte model.""" + + def refine( + self, + image: torch.Tensor, + mask: torch.Tensor, + settings: MaskRefinementSettings, + vitmatte_model: object | None, + ) -> torch.Tensor: + """Run ViTMatte over a batch of image/mask samples.""" + + if vitmatte_model is None: + raise ValueError( + "detail_method 'VITMatte' requires a connected VITMATTE_MODEL when " + "refine_mask is enabled. Connect ViTMatte Model Loader or choose " + "GuidedFilter." + ) + + loaded = unwrap_vitmatte_model(vitmatte_model) + output_device = mask.device + refined = [ + self._refine_sample(sample, sample_mask, settings, loaded) + for sample, sample_mask in zip(image, mask, strict=True) + ] + return torch.stack(refined).to(device=output_device, dtype=torch.float32) + + def _refine_sample( + self, + image: torch.Tensor, + mask: torch.Tensor, + settings: MaskRefinementSettings, + loaded_model: LoadedViTMatteModel, + ) -> torch.Tensor: + """Refine one image and mask sample.""" + + height = int(mask.shape[-2]) + width = int(mask.shape[-1]) + working_image = image.detach().cpu().float().clamp(0.0, 1.0) + working_mask = mask.detach().cpu().float().clamp(0.0, 1.0) + erode_radius = settings.detail_erode + dilate_radius = settings.detail_dilate + max_dimension = max(height, width) + if max_dimension > settings.max_size_pixels: + scale = settings.max_size_pixels / float(max_dimension) + scaled_height = max(1, int(round(height * scale))) + scaled_width = max(1, int(round(width * scale))) + working_image = _resize_image_sample( + working_image, scaled_height, scaled_width + ) + working_mask = _resize_mask_sample( + working_mask, scaled_height, scaled_width + ) + erode_radius = max(1, int(round(settings.detail_erode * scale))) + dilate_radius = max(1, int(round(settings.detail_dilate * scale))) + + trimap = generate_vitmatte_trimap( + working_mask, + erode_radius, + dilate_radius, + ) + alpha = _run_vitmatte( + loaded_model, + _tensor_to_rgb_pil(working_image), + _trimap_to_pil(trimap), + settings.execution_device, + ) + if tuple(alpha.shape) != (height, width): + alpha = _resize_mask_sample(alpha, height, width) + return alpha.float().clamp(0.0, 1.0) + + +def generate_vitmatte_trimap( + mask: torch.Tensor, + erode_radius: int, + dilate_radius: int, +) -> torch.Tensor: + """Create a 0/0.5/1 trimap from a rough mask.""" + + mask_bchw = mask.float().clamp(0.0, 1.0).reshape(1, 1, *mask.shape[-2:]) + foreground = _erode(mask_bchw, erode_radius).squeeze(0).squeeze(0) > 0.99 + background = _dilate(mask_bchw, dilate_radius).squeeze(0).squeeze(0) < 0.01 + trimap = torch.full_like(mask.float(), 0.5) + trimap[foreground] = 1.0 + trimap[background] = 0.0 + return trimap + + +def _run_vitmatte( + loaded_model: LoadedViTMatteModel, + image: Image.Image, + trimap: Image.Image, + execution_device: str, +) -> torch.Tensor: + """Run one ViTMatte inference and return an HW alpha tensor.""" + + if loaded_model.managed_model is not None: + manager = TorchModelDeviceManager() + with manager.inference(loaded_model.managed_model, execution_device) as loaded: + alpha = _run_vitmatte_on_device( + loaded.model, + loaded_model.processor, + image, + trimap, + loaded.device, + ) + else: + with external_model_inference(loaded_model.model, execution_device) as loaded: + alpha = _run_vitmatte_on_device( + loaded.model, + loaded_model.processor, + image, + trimap, + loaded.device, + ) + + while alpha.ndim > 2 and 1 in alpha.shape: + alpha = alpha.squeeze(0) + if alpha.ndim == 3: + alpha = alpha[0] + if alpha.ndim != 2: + raise ValueError( + f"ViTMatte returned invalid alpha shape: {tuple(alpha.shape)}." + ) + return alpha.clamp(0.0, 1.0) + + +def _run_vitmatte_on_device( + model: object, + processor: object, + image: Image.Image, + trimap: Image.Image, + device: torch.device, +) -> torch.Tensor: + """Run callable ViTMatte objects with inputs on `device`.""" + + if not callable(processor): + raise TypeError("VITMATTE_MODEL processor is not callable.") + inputs = processor(images=image, trimaps=trimap, return_tensors="pt") + tensor_inputs = { + key: value.to(device) if isinstance(value, torch.Tensor) else value + for key, value in dict(inputs).items() + } + with torch.no_grad(): + outputs = cast(Any, model)(**tensor_inputs) + return torch.as_tensor(outputs.alphas).detach().cpu().float() + + +def _resize_mask_sample(mask: torch.Tensor, height: int, width: int) -> torch.Tensor: + """Resize one HW mask sample.""" + + return F.interpolate( + mask.reshape(1, 1, *mask.shape[-2:]), + size=(height, width), + mode="bilinear", + align_corners=False, + ).reshape(height, width) + + +def _resize_image_sample(image: torch.Tensor, height: int, width: int) -> torch.Tensor: + """Resize one HWC image sample.""" + + return ( + F.interpolate( + image.movedim(-1, 0).unsqueeze(0), + size=(height, width), + mode="bilinear", + align_corners=False, + ) + .squeeze(0) + .movedim(0, -1) + ) + + +def _tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image: + """Convert one HWC image tensor to RGB PIL.""" + + import numpy as np + + array = image.detach().cpu().float().clamp(0.0, 1.0).numpy() + if array.shape[-1] == 1: + array = np.repeat(array, 3, axis=-1) + if array.shape[-1] == 4: + array = array[..., :3] + return Image.fromarray((array * 255.0).round().astype(np.uint8)) + + +def _trimap_to_pil(trimap: torch.Tensor) -> Image.Image: + """Convert an HW trimap tensor to grayscale PIL.""" + + import numpy as np + + array = trimap.detach().cpu().float().clamp(0.0, 1.0).numpy() + return Image.fromarray((array * 255.0).round().astype(np.uint8)) + + +def _dilate(samples: torch.Tensor, radius: int) -> torch.Tensor: + """Dilate BCHW mask samples.""" + + if radius <= 0: + return samples + padded = F.pad(samples, (radius, radius, radius, radius), value=0.0) + return F.max_pool2d(padded, kernel_size=radius * 2 + 1, stride=1) + + +def _erode(samples: torch.Tensor, radius: int) -> torch.Tensor: + """Erode BCHW mask samples.""" + + if radius <= 0: + return samples + padded = F.pad(samples, (radius, radius, radius, radius), value=0.0) + return -F.max_pool2d(-padded, kernel_size=radius * 2 + 1, stride=1) diff --git a/simple_syrup/runtime/wd14_tagger.py b/simple_syrup/runtime/wd14_tagger.py new file mode 100644 index 0000000..41ee78e --- /dev/null +++ b/simple_syrup/runtime/wd14_tagger.py @@ -0,0 +1,260 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Batched WD14 tagger runtime for tile prompt generation.""" + +from __future__ import annotations + +import csv +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol, cast + +import numpy as np +import torch +from numpy.typing import NDArray +from PIL import Image + +from ..shared.logging import get_logger +from .loaded_models import LoadedWD14Tagger +from .progress import NullProgressReporter, ProgressReporter + +LOGGER = get_logger(__name__) +FloatArray = NDArray[np.float32] + + +class WD14Session(Protocol): + """Minimal ONNX session surface used by the tagger.""" + + def get_inputs(self) -> list[Any]: + """Return model input metadata.""" + + def get_outputs(self) -> list[Any]: + """Return model output metadata.""" + + def run(self, output_names: list[str], feeds: dict[str, FloatArray]) -> list[Any]: + """Run inference and return model outputs.""" + + +@dataclass(frozen=True) +class WD14TagFormattingControls: + """Store validated WD14 tag formatting controls.""" + + threshold: float = 0.35 + character_threshold: float = 1.0 + replace_underscore: bool = True + trailing_comma: bool = False + exclude_tags: str = "" + + def __post_init__(self) -> None: + """Reject impossible threshold values.""" + + if not 0.0 <= self.threshold <= 1.0: + raise ValueError("threshold must be between 0.0 and 1.0.") + if not 0.0 <= self.character_threshold <= 1.0: + raise ValueError("character_threshold must be between 0.0 and 1.0.") + + +@dataclass(frozen=True) +class WD14TagRecord: + """Represent one WD14 tag CSV row.""" + + name: str + category: str + + +class WD14Tagger: + """Tag ordered image crops through a loaded batched WD14 ONNX session.""" + + def __init__(self, chunk_size: int | None = None) -> None: + """Create the runtime with optional fixed-size batch chunks.""" + + self._chunk_size = chunk_size + + def tag_images( + self, + loaded_tagger: LoadedWD14Tagger, + images: Sequence[torch.Tensor], + controls: WD14TagFormattingControls, + progress: ProgressReporter | None = None, + ) -> tuple[str, ...]: + """Return one WD14 tag prompt for each input image in order.""" + + if not images: + return () + reporter = progress or NullProgressReporter() + session = loaded_tagger.session + input_meta = session.get_inputs()[0] + output_meta = session.get_outputs()[0] + input_size = _input_size(input_meta) + batch = cast( + FloatArray, + np.concatenate( + tuple(_preprocess_image(image, input_size) for image in images), + axis=0, + ), + ) + probabilities = self._run_batches( + session, + output_meta.name, + input_meta.name, + batch, + reporter, + ) + if probabilities.ndim != 2 or int(probabilities.shape[0]) != len(images): + raise ValueError( + "WD14 output shape must be [batch, tags]; " + f"received {probabilities.shape} for {len(images)} images." + ) + if int(probabilities.shape[1]) != len(loaded_tagger.tags): + raise ValueError( + "WD14 output tag count does not match selected_tags.csv; " + f"received {probabilities.shape[1]} probabilities for " + f"{len(loaded_tagger.tags)} tags." + ) + return tuple( + _format_tags(row, loaded_tagger.tags, controls) for row in probabilities + ) + + def _run_batches( + self, + session: WD14Session, + output_name: str, + input_name: str, + batch: FloatArray, + progress: ProgressReporter, + ) -> FloatArray: + """Run a full input batch, optionally in fixed-size chunks.""" + + if self._chunk_size is None or self._chunk_size >= int(batch.shape[0]): + output = session.run([output_name], {input_name: batch})[0] + progress.update(int(batch.shape[0])) + return cast(FloatArray, np.asarray(output, dtype=np.float32)) + chunks: list[FloatArray] = [] + for start in range(0, int(batch.shape[0]), self._chunk_size): + chunk = batch[start : start + self._chunk_size] + output = session.run([output_name], {input_name: chunk})[0] + chunks.append(cast(FloatArray, np.asarray(output, dtype=np.float32))) + progress.update(int(chunk.shape[0])) + return cast(FloatArray, np.concatenate(tuple(chunks), axis=0)) + + +def load_wd14_tags(csv_path: Path) -> tuple[WD14TagRecord, ...]: + """Load WD14 tag records from selected_tags.csv.""" + + records: list[WD14TagRecord] = [] + with csv_path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.reader(handle) + try: + next(reader) + except StopIteration as exc: + raise ValueError(f"WD14 tag CSV is empty: {csv_path}.") from exc + for row in reader: + if len(row) < 3: + raise ValueError(f"WD14 tag CSV has an invalid row: {row}.") + records.append(WD14TagRecord(name=row[1], category=row[2])) + if not records: + raise ValueError(f"WD14 tag CSV has no tag rows: {csv_path}.") + return tuple(records) + + +def _input_size(input_meta: Any) -> int: + """Return the square WD14 input size from ONNX metadata.""" + + shape = cast(Sequence[Any], input_meta.shape) + if len(shape) >= 3: + try: + height = int(shape[1]) + if height > 0: + return height + except (TypeError, ValueError): + pass + return 448 + + +def _preprocess_image(image: torch.Tensor, input_size: int) -> FloatArray: + """Resize and pad a BHWC image into one WD14 BGR batch row.""" + + if image.ndim != 4 or int(image.shape[0]) != 1: + raise ValueError("WD14 tile crops must be single-image BHWC tensors.") + tensor = image.detach().cpu().float().clamp(0.0, 1.0) + array = np.asarray(tensor[0].numpy() * 255.0, dtype=np.uint8) + if array.shape[-1] == 1: + array = np.repeat(array, 3, axis=-1) + elif array.shape[-1] > 3: + array = array[:, :, :3] + pil_image = Image.fromarray(array, mode="RGB") + ratio = float(input_size) / max(pil_image.size) + resized_size = ( + max(1, int(pil_image.size[0] * ratio)), + max(1, int(pil_image.size[1] * ratio)), + ) + resized = pil_image.resize(resized_size, Image.Resampling.LANCZOS) + square = Image.new("RGB", (input_size, input_size), (255, 255, 255)) + square.paste( + resized, + ((input_size - resized_size[0]) // 2, (input_size - resized_size[1]) // 2), + ) + processed = np.asarray(square, dtype=np.float32)[:, :, ::-1] + return cast(FloatArray, np.expand_dims(processed, axis=0)) + + +def _format_tags( + probabilities: FloatArray, + records: tuple[WD14TagRecord, ...], + controls: WD14TagFormattingControls, +) -> str: + """Format thresholded WD14 probabilities into one prompt string.""" + + selected: list[str] = [] + excluded = _excluded_tags(controls) + for probability, record in zip(probabilities, records, strict=True): + threshold = _threshold_for_category(record.category, controls) + if threshold is None or float(probability) <= threshold: + continue + name = _display_name(record.name, controls.replace_underscore) + if name.lower() in excluded: + continue + selected.append(_escape_prompt_tag(name)) + if controls.trailing_comma: + return "".join(f"{tag}, " for tag in selected) + return ", ".join(selected) + + +def _threshold_for_category( + category: str, + controls: WD14TagFormattingControls, +) -> float | None: + """Return the threshold for an emitted tag category.""" + + if category == "0": + return controls.threshold + if category == "4": + return controls.character_threshold + return None + + +def _display_name(name: str, replace_underscore: bool) -> str: + """Return the prompt-facing form of a WD14 tag.""" + + if replace_underscore: + return name.replace("_", " ") + return name + + +def _excluded_tags(controls: WD14TagFormattingControls) -> set[str]: + """Return normalized excluded tag names.""" + + return { + _display_name(tag.strip(), controls.replace_underscore).lower() + for tag in controls.exclude_tags.split(",") + if tag.strip() + } + + +def _escape_prompt_tag(name: str) -> str: + """Escape prompt syntax characters emitted by WD14 tags.""" + + return name.replace("(", "\\(").replace(")", "\\)") diff --git a/simple_syrup/runtime/wd14_tagger_loader.py b/simple_syrup/runtime/wd14_tagger_loader.py new file mode 100644 index 0000000..6752acf --- /dev/null +++ b/simple_syrup/runtime/wd14_tagger_loader.py @@ -0,0 +1,288 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Resolve, download, and load WD14 tagger models.""" + +from __future__ import annotations + +from collections.abc import Callable, MutableMapping +from dataclasses import dataclass +from importlib import import_module +from pathlib import Path +from types import ModuleType +from typing import Protocol, cast + +from ..shared.logging import get_logger +from .loaded_models import LoadedWD14Tagger +from .model_catalog import ModelEntry, get_wd14_tagger_entry +from .model_downloads import ( + DownloadRequest, + DownloadResult, + ModelDownloader, + ProgressReporter, +) +from .model_folders import ( + expected_model_file, + register_required_model_folders, + resolve_model_file, +) +from .model_instance_cache import ModelInstanceCache +from .wd14_tagger import WD14Session, WD14TagRecord, load_wd14_tags + +LOGGER = get_logger(__name__) +DEFAULT_WD14_PROVIDERS = ("CUDAExecutionProvider", "CPUExecutionProvider") + +SessionFactory = Callable[[Path, tuple[str, ...]], WD14Session] +TagLoader = Callable[[Path], tuple[WD14TagRecord, ...]] + + +class ArtifactDownloader(Protocol): + """Download one trusted model artifact.""" + + def download( + self, + request: DownloadRequest, + progress: ProgressReporter | None = None, + ) -> DownloadResult: + """Download an artifact and return its resolved path.""" + + +@dataclass(frozen=True) +class WD14TaggerCacheKey: + """Identify a loaded WD14 runtime for process-level reuse.""" + + model_id: str + onnx_path: Path + csv_path: Path + providers: tuple[str, ...] + + +@dataclass(frozen=True) +class WD14TaggerArtifactPaths: + """Store resolved WD14 model artifact paths and source metadata.""" + + onnx_path: Path + csv_path: Path + source: str + downloaded: bool + + +_LOADED_WD14_TAGGERS: dict[WD14TaggerCacheKey, LoadedWD14Tagger] = {} + + +class WD14TaggerLoaderService: + """Resolve, download, and load known WD14 tagger models.""" + + def __init__( + self, + downloader: ArtifactDownloader | None = None, + folder_paths_module: ModuleType | None = None, + session_factory: SessionFactory | None = None, + tag_loader: TagLoader | None = None, + providers: tuple[str, ...] | None = None, + cache: MutableMapping[WD14TaggerCacheKey, LoadedWD14Tagger] | None = None, + ) -> None: + """Create a WD14 loader with injectable external boundaries.""" + + self._downloader = downloader or ModelDownloader() + self._folder_paths_module = folder_paths_module + self._session_factory = session_factory or _create_onnx_session + self._tag_loader = tag_loader or load_wd14_tags + self._providers = providers + self._cache: ModelInstanceCache[WD14TaggerCacheKey, LoadedWD14Tagger] = ( + ModelInstanceCache(cache if cache is not None else _LOADED_WD14_TAGGERS) + ) + + def load_model( + self, + wd14_model: str, + auto_download: bool, + progress: ProgressReporter | None = None, + ) -> LoadedWD14Tagger: + """Load a known WD14 tagger and return a `WD14_TAGGER` object.""" + + register_required_model_folders(self._folder_paths_module) + entry = get_wd14_tagger_entry(wd14_model) + artifacts = self._resolve_artifacts(entry, auto_download, progress) + providers = self._available_providers() + key = WD14TaggerCacheKey( + model_id=entry.entry_id, + onnx_path=artifacts.onnx_path.resolve(), + csv_path=artifacts.csv_path.resolve(), + providers=providers, + ) + already_loaded = key in self._cache.entries + loaded = self._cache.get_or_load( + key, + lambda: self._load_uncached_tagger(entry, artifacts, providers), + ) + if already_loaded: + LOGGER.info( + "WD14 tagger loaded from process cache", + extra={ + "operation": "wd14_tagger_loader", + "model": entry.entry_id, + "onnx_path": str(artifacts.onnx_path), + "csv_path": str(artifacts.csv_path), + "providers": providers, + }, + ) + return loaded + + def _load_uncached_tagger( + self, + entry: ModelEntry, + artifacts: WD14TaggerArtifactPaths, + providers: tuple[str, ...], + ) -> LoadedWD14Tagger: + """Load a WD14 tagger after artifact resolution and cache lookup.""" + + session = self._session_factory(artifacts.onnx_path, providers) + tags = self._tag_loader(artifacts.csv_path) + loaded = LoadedWD14Tagger( + model_id=entry.entry_id, + source=artifacts.source, + onnx_path=artifacts.onnx_path, + csv_path=artifacts.csv_path, + providers=providers, + session=session, + tags=tags, + ) + LOGGER.info( + "WD14 tagger loaded", + extra={ + "operation": "wd14_tagger_loader", + "model": entry.entry_id, + "onnx_path": str(artifacts.onnx_path), + "csv_path": str(artifacts.csv_path), + "providers": providers, + "downloaded": artifacts.downloaded, + }, + ) + return loaded + + def _resolve_artifacts( + self, + entry: ModelEntry, + auto_download: bool, + progress: ProgressReporter | None, + ) -> WD14TaggerArtifactPaths: + """Resolve or download the ONNX and tag CSV artifacts for an entry.""" + + paths: dict[str, Path] = {} + downloaded = False + for artifact in entry.artifacts: + existing = resolve_model_file( + artifact.folder_name, + artifact.filename, + self._folder_paths_module, + ) + if existing is not None: + paths[artifact.artifact_id] = existing + continue + + destination = expected_model_file( + artifact.folder_name, + artifact.filename, + self._folder_paths_module, + ) + if not auto_download or not entry.auto_download_allowed: + raise FileNotFoundError( + f"WD14 tagger model '{entry.display_name}' is missing and " + "auto_download is disabled. Expected: " + f"{_expected_paths(entry)}. Enable auto_download on Load " + "WD14 Tagger or install the ONNX and CSV files." + ) + result = self._downloader.download( + DownloadRequest( + source_url=artifact.source_url, + destination_path=destination, + expected_folder=destination.parent, + description=artifact.description, + ), + progress, + ) + paths[artifact.artifact_id] = result.path + downloaded = downloaded or not result.skipped_existing + + onnx_path = _required_artifact(paths, "onnx", entry) + csv_path = _required_artifact(paths, "tags", entry) + source = f"downloaded: {entry.source_repo}" if downloaded else "local" + return WD14TaggerArtifactPaths( + onnx_path=onnx_path, + csv_path=csv_path, + source=source, + downloaded=downloaded, + ) + + def _available_providers(self) -> tuple[str, ...]: + """Return requested ONNX Runtime providers with unavailable entries removed.""" + + requested = self._providers or DEFAULT_WD14_PROVIDERS + ort = import_module("onnxruntime") + available = set(cast(list[str], ort.get_available_providers())) + providers = tuple(provider for provider in requested if provider in available) + if providers: + if providers != requested: + LOGGER.warning( + "WD14 provider fallback selected", + extra={ + "operation": "wd14_tagger_loader", + "requested_providers": requested, + "providers": providers, + }, + ) + else: + LOGGER.debug( + "WD14 providers selected", + extra={ + "operation": "wd14_tagger_loader", + "requested_providers": requested, + "providers": providers, + }, + ) + return providers + if "CPUExecutionProvider" not in available: + raise RuntimeError( + "No requested ONNX Runtime providers are available for WD14 tagger. " + f"Requested: {requested}. Available: {tuple(sorted(available))}." + ) + LOGGER.warning( + "WD14 provider fallback selected", + extra={ + "operation": "wd14_tagger_loader", + "requested_providers": requested, + "providers": ("CPUExecutionProvider",), + }, + ) + return ("CPUExecutionProvider",) + + +def _create_onnx_session(onnx_path: Path, providers: tuple[str, ...]) -> WD14Session: + """Create an ONNX Runtime inference session.""" + + ort = import_module("onnxruntime") + return cast(WD14Session, ort.InferenceSession(str(onnx_path), providers=providers)) + + +def _required_artifact( + paths: dict[str, Path], + artifact_id: str, + entry: ModelEntry, +) -> Path: + """Return a required artifact path or fail with catalog context.""" + + path = paths.get(artifact_id) + if path is None or not path.is_file(): + raise FileNotFoundError( + f"WD14 tagger model '{entry.display_name}' is incomplete. Expected: " + f"{_expected_paths(entry)}." + ) + return path + + +def _expected_paths(entry: ModelEntry) -> str: + """Return a readable list of expected artifact filenames for an entry.""" + + return " and ".join(artifact.filename for artifact in entry.artifacts) diff --git a/simple_syrup/services/__init__.py b/simple_syrup/services/__init__.py new file mode 100644 index 0000000..bcb22a9 --- /dev/null +++ b/simple_syrup/services/__init__.py @@ -0,0 +1,9 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Application services for SimpleSyrup node behavior.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/simple_syrup/services/detail_segs_as_regions_service.py b/simple_syrup/services/detail_segs_as_regions_service.py new file mode 100644 index 0000000..ccf6237 --- /dev/null +++ b/simple_syrup/services/detail_segs_as_regions_service.py @@ -0,0 +1,419 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Portions of this file incorporate behavior derived from +# multidiffusion-upscaler-for-automatic1111. See third_party/manifest.toml and +# third_party/NOTICE.md. + +"""SEGS detailer orchestration service using regional MultiDiffusion.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol + +import torch + +from ..domain.conditioning_batch import ConditioningBatch +from ..domain.regional_detailing import ( + LatentRegion, + pair_segments_with_conditioning, +) +from ..domain.segs import CropRegion, coerce_segs +from ..masking.regional_detailing_masks import ( + build_image_regions, + build_latent_regions, + feather_image_mask, + scale_image_regions, + union_masks, +) +from ..masking.segs_mask_ops import validate_single_image +from ..runtime import regional_multidiffusion_sampling +from ..runtime.detail_previews import DetailPreviewContext, work_region_from_mask +from ..runtime.detail_resize import DetailImageResizer +from ..runtime.detail_sampling import DetailSampler, Latent +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) +OPERATION = "Detail SEGS as Regions" + + +class RegionalDetailSamplingBoundary(Protocol): + """Runtime boundary used by Detail SEGS as Regions.""" + + def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent: + """Encode pixels into a latent dictionary.""" + + def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor: + """Decode latent samples into pixels.""" + + def sample_regions( + self, + *, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + regions: tuple[LatentRegion, ...], + denoise: float, + global_prompt_weight: float, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Sample one full latent with paired regional conditioning.""" + + def apply_differential_diffusion(self, model: Any) -> Any: + """Return a model patched for feathered denoise masks.""" + + +class RegionalDetailResizeBoundary(Protocol): + """Image resize boundary used by regional detailing.""" + + def resize_up( + self, + image: torch.Tensor, + height: int, + width: int, + method: str, + ) -> torch.Tensor: + """Resize an image using the selected upscale method.""" + + def resize_down_lanczos( + self, + image: torch.Tensor, + height: int, + width: int, + ) -> torch.Tensor: + """Resize an image using fixed Lanczos downscaling.""" + + +@dataclass(frozen=True) +class DetailSEGSAsRegionsResult: + """Return the detailed image from a regional detail pass.""" + + image: torch.Tensor + + +class RegionalDetailSampler: + """Adapt shared detail sampling helpers to regional MultiDiffusion.""" + + def __init__(self, detail_sampler: DetailSampler | None = None) -> None: + """Create the runtime adapter with injectable encode/decode helper.""" + + self._detail_sampler = detail_sampler or DetailSampler() + + def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent: + """Encode pixels into a latent dictionary.""" + + return self._detail_sampler.encode(vae, pixels, tiled) + + def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor: + """Decode latent samples into pixels.""" + + return self._detail_sampler.decode(vae, latent, tiled) + + def sample_regions( + self, + *, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + regions: tuple[LatentRegion, ...], + denoise: float, + global_prompt_weight: float, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Sample one full latent with regional MultiDiffusion.""" + + return regional_multidiffusion_sampling.sample_regional_multidiffusion( + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + regions=regions, + denoise=denoise, + global_prompt_weight=global_prompt_weight, + preview_context=preview_context, + ) + + def apply_differential_diffusion(self, model: Any) -> Any: + """Patch a model for feathered denoise masks when ComfyUI supports it.""" + + return self._detail_sampler.apply_differential_diffusion(model) + + +class DetailSEGSAsRegionsService: + """Detail provided SEGS through one regional MultiDiffusion pass.""" + + def __init__( + self, + sampler: RegionalDetailSamplingBoundary | None = None, + image_resizer: RegionalDetailResizeBoundary | None = None, + ) -> None: + """Create the service with injectable collaborators for tests.""" + + self._sampler = sampler or RegionalDetailSampler() + self._image_resizer = image_resizer or DetailImageResizer() + + def detail( + self, + image: object, + segs: object, + model: Any, + vae: Any, + positive: Any, + negative: Any, + region_positive: object, + scale_factor: float, + upscale_method: str, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + denoise: float, + feather: int, + noise_mask: bool, + noise_mask_feather: int, + tiled_encode: bool, + tiled_decode: bool, + global_prompt_weight: float, + ) -> DetailSEGSAsRegionsResult: + """Run regional MultiDiffusion detailing for provided SEGS.""" + + self._validate_sampling_inputs( + steps=steps, + denoise=denoise, + feather=feather, + noise_mask_feather=noise_mask_feather, + global_prompt_weight=global_prompt_weight, + scale_factor=scale_factor, + ) + image_tensor = validate_single_image(image, OPERATION) + native_segs = coerce_segs(segs) + image_height = int(image_tensor.shape[1]) + image_width = int(image_tensor.shape[2]) + scaled_height, scaled_width = _scaled_dimensions( + image_height, + image_width, + scale_factor, + ) + scaling_active = (scaled_height, scaled_width) != (image_height, image_width) + pairs = pair_segments_with_conditioning( + native_segs, + region_positive, + image_height=image_height, + image_width=image_width, + ) + if not pairs: + return DetailSEGSAsRegionsResult(image=image_tensor.clone()) + if not isinstance(region_positive, ConditioningBatch): + raise TypeError( + f"{OPERATION} requires region_positive to be CONDITIONING_BATCH." + ) + + image_regions = build_image_regions( + pairs, + image_height=image_height, + image_width=image_width, + ) + image_union_mask = union_masks( + tuple(region.image_mask for region in image_regions) + ) + working_image = ( + self._image_resizer.resize_up( + image_tensor, + scaled_height, + scaled_width, + upscale_method, + ) + if scaling_active + else image_tensor + ) + sampling_regions = ( + scale_image_regions( + image_regions, + image_height=scaled_height, + image_width=scaled_width, + ) + if scaling_active + else image_regions + ) + latent = self._sampler.encode(vae, working_image, tiled_encode) + samples = latent.get("samples") + if not isinstance(samples, torch.Tensor): + raise ValueError("latent samples must be a torch tensor.") + latent_height = int(samples.shape[-2]) + latent_width = int(samples.shape[-1]) + latent_regions = build_latent_regions( + sampling_regions, + latent_height=latent_height, + latent_width=latent_width, + device=samples.device, + dtype=samples.dtype, + latent_feather=noise_mask_feather, + ) + + latent_for_sampling = ( + self._with_noise_mask(latent, latent_regions) if noise_mask else latent + ) + sampling_model = model + if noise_mask and noise_mask_feather > 0: + sampling_model = self._sampler.apply_differential_diffusion(model) + + sampled = self._sampler.sample_regions( + model=sampling_model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_for_sampling, + regions=latent_regions, + denoise=denoise, + global_prompt_weight=global_prompt_weight, + preview_context=DetailPreviewContext( + image=image_tensor, + work_region=work_region_from_mask(image_union_mask), + work_mask=image_union_mask, + sampled_region=CropRegion(0, 0, image_width, image_height), + ), + ) + decoded = self._sampler.decode(vae, sampled, tiled_decode) + if decoded.shape[1:3] != image_tensor.shape[1:3]: + decoded = self._image_resizer.resize_down_lanczos( + decoded, + image_height, + image_width, + ) + detailed = self._composite_full_image( + image_tensor, + decoded, + feather_image_mask(image_union_mask, feather), + ) + + LOGGER.info( + "Detail SEGS as Regions pass completed", + extra={ + "operation": "detail_segs_as_regions", + "segment_count": len(pairs), + "latent_width": latent_width, + "latent_height": latent_height, + "latent_ndim": samples.ndim, + "sampler": sampler_name, + "scheduler": scheduler, + "steps": steps, + "denoise": denoise, + "noise_mask": noise_mask, + "region_positive_count": len(region_positive.entries), + "global_prompt_weight": global_prompt_weight, + "scale_factor": scale_factor, + "upscale_method": upscale_method, + "original_width": image_width, + "original_height": image_height, + "scaled_width": scaled_width, + "scaled_height": scaled_height, + }, + ) + return DetailSEGSAsRegionsResult(image=detailed) + + def _with_noise_mask( + self, + latent: Latent, + regions: tuple[LatentRegion, ...], + ) -> Latent: + """Attach a latent-sized union denoise mask to a latent dictionary.""" + + samples = latent.get("samples") + if not isinstance(samples, torch.Tensor): + raise ValueError("latent samples must be a torch tensor.") + latent_mask = union_masks(tuple(region.latent_mask for region in regions)) + output = latent.copy() + output["noise_mask"] = latent_mask.unsqueeze(0).to( + device=samples.device, + dtype=samples.dtype, + ) + return output + + def _composite_full_image( + self, + original: torch.Tensor, + decoded: torch.Tensor, + mask: torch.Tensor, + ) -> torch.Tensor: + """Composite a full decoded image through a full-image HW mask.""" + + if decoded.ndim != 4: + raise ValueError("decoded image must be a BHWC tensor.") + working = decoded + if working.shape[1:3] != original.shape[1:3]: + working = self._image_resizer.resize_down_lanczos( + working, + int(original.shape[1]), + int(original.shape[2]), + ) + alpha = mask.to(device=original.device, dtype=original.dtype).unsqueeze(0) + alpha = alpha.unsqueeze(-1).clamp(0.0, 1.0) + return ( + (working.to(device=original.device, dtype=original.dtype) * alpha) + .add(original * (1.0 - alpha)) + .clamp(0.0, 1.0) + ) + + def _validate_sampling_inputs( + self, + *, + steps: int, + denoise: float, + feather: int, + noise_mask_feather: int, + global_prompt_weight: float, + scale_factor: float, + ) -> None: + """Reject invalid sampling controls before side effects.""" + + if steps < 1: + raise ValueError("steps must be at least 1.") + if not 0.0 <= denoise <= 1.0: + raise ValueError("denoise must be between 0 and 1.") + if feather < 0: + raise ValueError("feather must be greater than or equal to 0.") + if noise_mask_feather < 0: + raise ValueError("noise_mask_feather must be greater than or equal to 0.") + if not 0.0 <= global_prompt_weight <= 1.0: + raise ValueError("global_prompt_weight must be between 0.0 and 1.0.") + if scale_factor <= 0.0: + raise ValueError("scale_factor must be greater than 0.") + + +def _scaled_dimensions( + image_height: int, + image_width: int, + scale_factor: float, +) -> tuple[int, int]: + """Return detail canvas dimensions for a regional scale factor.""" + + if scale_factor <= 1.0: + return image_height, image_width + return ( + max(1, int(round(image_height * scale_factor))), + max(1, int(round(image_width * scale_factor))), + ) diff --git a/simple_syrup/services/detail_segs_by_scale_factor_service.py b/simple_syrup/services/detail_segs_by_scale_factor_service.py new file mode 100644 index 0000000..4e23d02 --- /dev/null +++ b/simple_syrup/services/detail_segs_by_scale_factor_service.py @@ -0,0 +1,309 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""SEGS detailer orchestration service using scale-factor sizing.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol + +import torch + +from ..domain.conditioning_batch import select_conditioning +from ..domain.detail_geometry import DetailScalePlan, build_detail_scale_plan +from ..domain.segs import Segment, coerce_segs +from ..image.crop_composite import composite_crop +from ..masking.segs_mask_ops import ( + crop_image, + feather_mask, + resize_mask, + validate_single_image, +) +from ..runtime.detail_previews import DetailPreviewContext +from ..runtime.detail_resize import DetailImageResizer +from ..runtime.detail_sampling import DetailSampler, Latent +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + + +class DetailSamplingBoundary(Protocol): + """Sampling boundary used by Detail SEGS by Scale Factor.""" + + def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent: + """Encode pixels into a latent dictionary.""" + + def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor: + """Decode latent samples into pixels.""" + + def sample( + self, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Sample one latent crop.""" + + def apply_differential_diffusion(self, model: Any) -> Any: + """Return a model patched for feathered denoise masks.""" + + +class DetailResizeBoundary(Protocol): + """Image resize boundary used by scale-factor detailing.""" + + def resize_up( + self, + image: torch.Tensor, + height: int, + width: int, + method: str, + ) -> torch.Tensor: + """Resize an image using the selected upscale method.""" + + def resize_down_lanczos( + self, + image: torch.Tensor, + height: int, + width: int, + ) -> torch.Tensor: + """Resize an image using fixed Lanczos downscaling.""" + + +@dataclass(frozen=True) +class DetailerResult: + """Return the detailed image from a detail pass.""" + + image: torch.Tensor + + +class DetailSEGSByScaleFactorService: + """Detail provided SEGS by scaling crops before inpaint sampling.""" + + def __init__( + self, + sampler: DetailSamplingBoundary | None = None, + image_resizer: DetailResizeBoundary | None = None, + ) -> None: + """Create the service with injectable collaborators for tests.""" + + self._sampler = sampler or DetailSampler() + self._image_resizer = image_resizer or DetailImageResizer() + + def detail( + self, + image: object, + segs: object, + model: Any, + vae: Any, + positive: Any, + negative: Any, + scale_factor: float, + upscale_method: str, + clamp_size: int, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + denoise: float, + feather: int, + noise_mask: bool, + noise_mask_feather: int, + tiled_encode: bool, + tiled_decode: bool, + ) -> DetailerResult: + """Run crop sampling and composite-back detailing for provided SEGS.""" + + self._validate_sampling_inputs( + steps=steps, + denoise=denoise, + feather=feather, + noise_mask_feather=noise_mask_feather, + ) + image_tensor = validate_single_image(image, "Detail SEGS by Scale Factor") + native_segs = coerce_segs(segs) + _header, segments = native_segs + if not segments: + return DetailerResult(image=image_tensor.clone()) + + working_image = image_tensor.clone() + sampling_model = model + if noise_mask and noise_mask_feather > 0: + sampling_model = self._sampler.apply_differential_diffusion(model) + + for index, segment in enumerate(segments): + plan = build_detail_scale_plan( + detected_width=segment.bbox.width, + detected_height=segment.bbox.height, + crop_width=segment.crop_region.width, + crop_height=segment.crop_region.height, + scale_factor=scale_factor, + clamp_size=clamp_size, + ) + if plan.scale <= 1.0: + plan = DetailScalePlan( + width=segment.crop_region.width, + height=segment.crop_region.height, + scale=1.0, + unclamped_long_side=plan.unclamped_long_side, + target_long_side=float( + max(segment.crop_region.width, segment.crop_region.height) + ), + ) + working_image = self._detail_segment( + working_image=working_image, + segment=segment, + plan=plan, + model=sampling_model, + vae=vae, + positive=select_conditioning(positive, index), + negative=select_conditioning(negative, index), + upscale_method=upscale_method, + seed=seed + index, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + denoise=denoise, + feather=feather, + noise_mask=noise_mask, + noise_mask_feather=noise_mask_feather, + tiled_encode=tiled_encode, + tiled_decode=tiled_decode, + ) + + LOGGER.info( + "Detail SEGS by Scale Factor pass completed", + extra={ + "operation": "detail_segs_by_scale_factor", + "scale_factor": scale_factor, + "upscale_method": upscale_method, + "clamp_size": clamp_size, + "sampler": sampler_name, + "scheduler": scheduler, + "segment_count": len(segments), + }, + ) + return DetailerResult(image=working_image) + + def _detail_segment( + self, + working_image: torch.Tensor, + segment: Segment, + plan: DetailScalePlan, + model: Any, + vae: Any, + positive: Any, + negative: Any, + upscale_method: str, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + denoise: float, + feather: int, + noise_mask: bool, + noise_mask_feather: int, + tiled_encode: bool, + tiled_decode: bool, + ) -> torch.Tensor: + """Detail one segment and return the updated working image.""" + + crop = crop_image(working_image, segment.crop_region) + full_mask = segment.cropped_mask + if isinstance(full_mask, torch.Tensor): + mask_crop = full_mask.float() + else: + mask_crop = torch.as_tensor(full_mask).float() + scaled_crop = self._image_resizer.resize_up( + crop, + plan.height, + plan.width, + upscale_method, + ) + scaled_mask = resize_mask(mask_crop, plan.height, plan.width) + latent = self._sampler.encode(vae, scaled_crop, tiled_encode) + if noise_mask: + latent = self._with_noise_mask(latent, scaled_mask, noise_mask_feather) + sampled = self._sampler.sample( + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent, + denoise=denoise, + preview_context=DetailPreviewContext( + image=working_image, + work_region=segment.crop_region, + work_mask=mask_crop, + ), + ) + decoded = self._sampler.decode(vae, sampled, tiled_decode) + resized_detail = self._image_resizer.resize_down_lanczos( + decoded, + segment.crop_region.height, + segment.crop_region.width, + ) + paste_mask = feather_mask(mask_crop, feather).to(device=working_image.device) + return composite_crop( + image=working_image, + crop=resized_detail, + mask=paste_mask, + region=segment.crop_region, + ) + + def _with_noise_mask( + self, + latent: Latent, + mask: torch.Tensor, + noise_mask_feather: int, + ) -> Latent: + """Attach a latent-sized denoise mask to a latent dictionary.""" + + samples = latent.get("samples") + if not isinstance(samples, torch.Tensor): + raise ValueError("latent samples must be a torch tensor.") + latent_height = int(samples.shape[-2]) + latent_width = int(samples.shape[-1]) + latent_mask = resize_mask(mask, latent_height, latent_width) + if noise_mask_feather > 0: + latent_mask = feather_mask(latent_mask, noise_mask_feather) + output = latent.copy() + output["noise_mask"] = latent_mask.unsqueeze(0).to( + device=samples.device, + dtype=samples.dtype, + ) + return output + + def _validate_sampling_inputs( + self, + steps: int, + denoise: float, + feather: int, + noise_mask_feather: int, + ) -> None: + """Reject invalid sampling controls before side effects.""" + + if steps < 1: + raise ValueError("steps must be at least 1.") + if not 0.0 <= denoise <= 1.0: + raise ValueError("denoise must be between 0 and 1.") + if feather < 0: + raise ValueError("feather must be greater than or equal to 0.") + if noise_mask_feather < 0: + raise ValueError("noise_mask_feather must be greater than or equal to 0.") diff --git a/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py b/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py new file mode 100644 index 0000000..150c22d --- /dev/null +++ b/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py @@ -0,0 +1,444 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""SEGS crop detailer orchestration using tiled diffusion sampling.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol + +import torch + +from ..domain.conditioning_batch import select_conditioning +from ..domain.detail_geometry import DetailScalePlan, build_detail_scale_plan +from ..domain.segs import Segment, coerce_segs +from ..domain.tiled_diffusion import validate_tiled_diffusion_mode +from ..image.crop_composite import composite_crop +from ..masking.segs_mask_ops import ( + crop_image, + feather_mask, + resize_mask, + validate_single_image, +) +from ..runtime.detail_previews import DetailPreviewContext +from ..runtime.detail_resize import DetailImageResizer +from ..runtime.detail_sampling import DetailSampler, Latent +from ..shared.logging import get_logger +from .tiled_diffusion_sampling_service import TiledDiffusionSamplingService + +LOGGER = get_logger(__name__) + + +class TiledDiffusionLatentSamplingBoundary(Protocol): + """Latent sampling boundary for selectable tiled diffusion modes.""" + + def sample( + self, + *, + diffusion_mode: str, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Sample a latent using the selected tiled diffusion mode.""" + + +class TiledDetailSamplingBoundary(Protocol): + """Runtime boundary used by tiled scale-factor detailing.""" + + def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent: + """Encode pixels into a latent dictionary.""" + + def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor: + """Decode latent samples into pixels.""" + + def sample_tiled( + self, + *, + diffusion_mode: str, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Sample one latent crop with the requested tiled diffusion mode.""" + + def apply_differential_diffusion(self, model: Any) -> Any: + """Return a model patched for feathered denoise masks.""" + + +class TiledDetailResizeBoundary(Protocol): + """Image resize boundary used by tiled scale-factor detailing.""" + + def resize_up( + self, + image: torch.Tensor, + height: int, + width: int, + method: str, + ) -> torch.Tensor: + """Resize an image using the selected upscale method.""" + + def resize_down_lanczos( + self, + image: torch.Tensor, + height: int, + width: int, + ) -> torch.Tensor: + """Resize an image using fixed Lanczos downscaling.""" + + +@dataclass(frozen=True) +class TiledDetailerResult: + """Return the detailed image from a tiled detail pass.""" + + image: torch.Tensor + + +class TiledDetailSampler: + """Adapt shared VAE helpers and tiled diffusion runtimes.""" + + def __init__( + self, + detail_sampler: DetailSampler | None = None, + tiled_sampling_service: TiledDiffusionLatentSamplingBoundary | None = None, + ) -> None: + """Create the adapter with injectable encode/decode behavior.""" + + self._detail_sampler = detail_sampler or DetailSampler() + self._tiled_sampling_service = ( + tiled_sampling_service or TiledDiffusionSamplingService() + ) + + def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent: + """Encode pixels into a latent dictionary.""" + + return self._detail_sampler.encode(vae, pixels, tiled) + + def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor: + """Decode latent samples into pixels.""" + + return self._detail_sampler.decode(vae, latent, tiled) + + def sample_tiled( + self, + *, + diffusion_mode: str, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Sample one latent crop with the selected tiled diffusion runtime.""" + + return self._tiled_sampling_service.sample( + diffusion_mode=diffusion_mode, + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=preview_context, + ) + + def apply_differential_diffusion(self, model: Any) -> Any: + """Patch a model for feathered denoise masks when ComfyUI supports it.""" + + return self._detail_sampler.apply_differential_diffusion(model) + + +class DetailSEGSByScaleFactorTiledDiffusionService: + """Detail SEGS crops with tiled diffusion latent sampling.""" + + def __init__( + self, + sampler: TiledDetailSamplingBoundary | None = None, + image_resizer: TiledDetailResizeBoundary | None = None, + ) -> None: + """Create the service with injectable collaborators for tests.""" + + self._sampler = sampler or TiledDetailSampler() + self._image_resizer = image_resizer or DetailImageResizer() + + def detail( + self, + image: object, + segs: object, + model: Any, + vae: Any, + positive: Any, + negative: Any, + scale_factor: float, + upscale_method: str, + clamp_size: int, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + denoise: float, + feather: int, + noise_mask: bool, + noise_mask_feather: int, + tiled_encode: bool, + tiled_decode: bool, + diffusion_mode: str, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + ) -> TiledDetailerResult: + """Run crop sampling and composite-back detailing with tiled diffusion.""" + + validate_tiled_diffusion_mode(diffusion_mode) + self._validate_sampling_inputs( + steps=steps, + denoise=denoise, + feather=feather, + noise_mask_feather=noise_mask_feather, + ) + image_tensor = validate_single_image( + image, + "Detail SEGS by Scale Factor w/ Tiled Diffusion", + ) + native_segs = coerce_segs(segs) + _header, segments = native_segs + if not segments: + return TiledDetailerResult(image=image_tensor.clone()) + + working_image = image_tensor.clone() + sampling_model = model + if noise_mask and noise_mask_feather > 0: + sampling_model = self._sampler.apply_differential_diffusion(model) + + for index, segment in enumerate(segments): + plan = build_detail_scale_plan( + detected_width=segment.bbox.width, + detected_height=segment.bbox.height, + crop_width=segment.crop_region.width, + crop_height=segment.crop_region.height, + scale_factor=scale_factor, + clamp_size=clamp_size, + ) + if plan.scale <= 1.0: + plan = DetailScalePlan( + width=segment.crop_region.width, + height=segment.crop_region.height, + scale=1.0, + unclamped_long_side=plan.unclamped_long_side, + target_long_side=float( + max(segment.crop_region.width, segment.crop_region.height) + ), + ) + working_image = self._detail_segment( + working_image=working_image, + segment=segment, + plan=plan, + model=sampling_model, + vae=vae, + positive=select_conditioning(positive, index), + negative=select_conditioning(negative, index), + upscale_method=upscale_method, + seed=seed + index, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + denoise=denoise, + feather=feather, + noise_mask=noise_mask, + noise_mask_feather=noise_mask_feather, + tiled_encode=tiled_encode, + tiled_decode=tiled_decode, + diffusion_mode=diffusion_mode, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + ) + + LOGGER.info( + "Detail SEGS by Scale Factor tiled diffusion pass completed", + extra={ + "operation": "detail_segs_by_scale_factor_tiled_diffusion", + "scale_factor": scale_factor, + "upscale_method": upscale_method, + "clamp_size": clamp_size, + "diffusion_mode": diffusion_mode, + "latent_tile_width": latent_tile_width, + "latent_tile_height": latent_tile_height, + "latent_tile_overlap": latent_tile_overlap, + "latent_tile_batch_size": latent_tile_batch_size, + "sampler": sampler_name, + "scheduler": scheduler, + "segment_count": len(segments), + }, + ) + return TiledDetailerResult(image=working_image) + + def _detail_segment( + self, + working_image: torch.Tensor, + segment: Segment, + plan: DetailScalePlan, + model: Any, + vae: Any, + positive: Any, + negative: Any, + upscale_method: str, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + denoise: float, + feather: int, + noise_mask: bool, + noise_mask_feather: int, + tiled_encode: bool, + tiled_decode: bool, + diffusion_mode: str, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + ) -> torch.Tensor: + """Detail one segment with tiled diffusion and return the updated image.""" + + crop = crop_image(working_image, segment.crop_region) + full_mask = segment.cropped_mask + if isinstance(full_mask, torch.Tensor): + mask_crop = full_mask.float() + else: + mask_crop = torch.as_tensor(full_mask).float() + scaled_crop = self._image_resizer.resize_up( + crop, + plan.height, + plan.width, + upscale_method, + ) + scaled_mask = resize_mask(mask_crop, plan.height, plan.width) + latent = self._sampler.encode(vae, scaled_crop, tiled_encode) + if noise_mask: + latent = self._with_noise_mask(latent, scaled_mask, noise_mask_feather) + sampled = self._sampler.sample_tiled( + diffusion_mode=diffusion_mode, + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=DetailPreviewContext( + image=working_image, + work_region=segment.crop_region, + work_mask=mask_crop, + ), + ) + decoded = self._sampler.decode(vae, sampled, tiled_decode) + resized_detail = self._image_resizer.resize_down_lanczos( + decoded, + segment.crop_region.height, + segment.crop_region.width, + ) + paste_mask = feather_mask(mask_crop, feather).to(device=working_image.device) + return composite_crop( + image=working_image, + crop=resized_detail, + mask=paste_mask, + region=segment.crop_region, + ) + + def _with_noise_mask( + self, + latent: Latent, + mask: torch.Tensor, + noise_mask_feather: int, + ) -> Latent: + """Attach a latent-sized denoise mask to a latent dictionary.""" + + samples = latent.get("samples") + if not isinstance(samples, torch.Tensor): + raise ValueError("latent samples must be a torch tensor.") + latent_height = int(samples.shape[-2]) + latent_width = int(samples.shape[-1]) + latent_mask = resize_mask(mask, latent_height, latent_width) + if noise_mask_feather > 0: + latent_mask = feather_mask(latent_mask, noise_mask_feather) + output = latent.copy() + output["noise_mask"] = latent_mask.unsqueeze(0).to( + device=samples.device, + dtype=samples.dtype, + ) + return output + + def _validate_sampling_inputs( + self, + steps: int, + denoise: float, + feather: int, + noise_mask_feather: int, + ) -> None: + """Reject invalid sampling controls before side effects.""" + + if steps < 1: + raise ValueError("steps must be at least 1.") + if not 0.0 <= denoise <= 1.0: + raise ValueError("denoise must be between 0 and 1.") + if feather < 0: + raise ValueError("feather must be greater than or equal to 0.") + if noise_mask_feather < 0: + raise ValueError("noise_mask_feather must be greater than or equal to 0.") diff --git a/simple_syrup/services/latent_diagnostics_service.py b/simple_syrup/services/latent_diagnostics_service.py new file mode 100644 index 0000000..b7507b8 --- /dev/null +++ b/simple_syrup/services/latent_diagnostics_service.py @@ -0,0 +1,217 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Latent metadata reporting for ComfyUI diagnostic nodes.""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +import torch + +LOGGER = logging.getLogger(__name__) + + +class LatentDiagnosticsService: + """Build deterministic reports for ComfyUI latent dictionaries.""" + + def describe(self, latent: Mapping[str, Any]) -> str: + """Return a readable latent metadata report without sampling tensor values.""" + + LOGGER.debug( + "describing_latent", + extra={ + "operation": "latent_diagnostics.describe", + "latent_key_count": len(latent), + }, + ) + lines = [ + "SimpleSyrup Latent Diagnostics", + f"latent_type: {type(latent).__module__}.{type(latent).__qualname__}", + f"latent_keys: {_format_keys(latent)}", + ] + + samples = latent.get("samples") + lines.extend(_describe_samples(samples)) + + other_keys = [key for key in sorted(latent) if key != "samples"] + if other_keys: + lines.append("other_entries:") + for key in other_keys: + lines.append(f" {key}: {_describe_value(latent[key])}") + else: + lines.append("other_entries: none") + + return "\n".join(lines) + + +def _format_keys(latent: Mapping[str, Any]) -> str: + """Return sorted latent keys in a compact display form.""" + + return "[" + ", ".join(sorted(latent)) + "]" + + +def _describe_samples(samples: object) -> list[str]: + """Return report lines for the primary latent samples entry.""" + + if not isinstance(samples, torch.Tensor): + return [ + "samples: missing or not a torch.Tensor", + f"samples_type: {_type_name(samples)}", + "mixture_of_diffusers_current_compatible: no", + "compatibility_reason: samples must be a torch.Tensor.", + ] + + is_nested = bool(getattr(samples, "is_nested", False)) + shape = tuple(int(dim) for dim in samples.shape) + lines = [ + "samples:", + f" type: {_type_name(samples)}", + f" shape: {list(shape)}", + f" ndim: {samples.ndim}", + f" dtype: {samples.dtype}", + f" device: {samples.device}", + f" layout: {samples.layout}", + f" is_nested: {is_nested}", + f" is_sparse: {samples.is_sparse}", + f" requires_grad: {samples.requires_grad}", + f" is_contiguous: {_safe_bool(samples.is_contiguous)}", + f" numel: {_safe_int(samples.numel)}", + f" stride: {_safe_sequence(samples.stride)}", + ] + lines.extend(_describe_shape_interpretation(samples, is_nested)) + return lines + + +def _describe_shape_interpretation( + samples: torch.Tensor, + is_nested: bool, +) -> list[str]: + """Return Mixture-of-Diffusers-relevant shape interpretation lines.""" + + if samples.ndim >= 2: + lines = [ + "spatial_last_dims:", + f" height: {int(samples.shape[-2])}", + f" width: {int(samples.shape[-1])}", + ] + else: + lines = ["spatial_last_dims: unavailable"] + + if is_nested: + lines.extend( + [ + "mixture_of_diffusers_current_compatible: no", + "compatibility_reason: samples is a nested tensor.", + ] + ) + elif samples.ndim == 4: + lines.extend( + [ + "bchw_interpretation:", + f" batch: {int(samples.shape[0])}", + f" channels: {int(samples.shape[1])}", + f" height: {int(samples.shape[2])}", + f" width: {int(samples.shape[3])}", + "mixture_of_diffusers_current_compatible: yes", + ] + ) + elif samples.ndim == 5 and int(samples.shape[2]) == 1: + lines.extend( + [ + "bcdhw_interpretation:", + f" batch: {int(samples.shape[0])}", + f" channels: {int(samples.shape[1])}", + f" depth: {int(samples.shape[2])}", + f" height: {int(samples.shape[3])}", + f" width: {int(samples.shape[4])}", + "mixture_of_diffusers_current_compatible: yes", + ] + ) + elif samples.ndim == 5: + lines.extend( + [ + "bcdhw_interpretation:", + f" batch: {int(samples.shape[0])}", + f" channels: {int(samples.shape[1])}", + f" depth: {int(samples.shape[2])}", + f" height: {int(samples.shape[3])}", + f" width: {int(samples.shape[4])}", + "mixture_of_diffusers_current_compatible: no", + ( + "compatibility_reason: current Mixture of Diffusers sampler " + "expects 5D samples to use a singleton depth axis." + ), + ] + ) + else: + lines.extend( + [ + "bchw_interpretation: unavailable", + "mixture_of_diffusers_current_compatible: no", + ( + "compatibility_reason: current Mixture of Diffusers sampler " + "expects non-nested 4D BCHW or singleton-depth 5D BCDHW samples." + ), + ] + ) + return lines + + +def _describe_value(value: object) -> str: + """Return one-line metadata for a non-primary latent entry.""" + + if isinstance(value, torch.Tensor): + return ( + f"{_type_name(value)} shape={list(value.shape)} ndim={value.ndim} " + f"dtype={value.dtype} device={value.device}" + ) + if isinstance(value, list | tuple): + return f"{_type_name(value)} len={len(value)}" + if isinstance(value, Mapping): + return f"{_type_name(value)} keys={_format_mapping_keys(value)}" + if value is None: + return "None" + return _type_name(value) + + +def _format_mapping_keys(value: Mapping[object, object]) -> str: + """Return stable display text for mapping keys.""" + + return "[" + ", ".join(sorted(str(key) for key in value)) + "]" + + +def _type_name(value: object) -> str: + """Return a fully qualified type name for diagnostics.""" + + return f"{type(value).__module__}.{type(value).__qualname__}" + + +def _safe_bool(method: Any) -> str: + """Return a boolean method result or a diagnostic error marker.""" + + try: + return str(bool(method())) + except RuntimeError as error: + return f"unavailable ({error.__class__.__name__}: {error})" + + +def _safe_int(method: Any) -> str: + """Return an integer method result or a diagnostic error marker.""" + + try: + return str(int(method())) + except RuntimeError as error: + return f"unavailable ({error.__class__.__name__}: {error})" + + +def _safe_sequence(method: Any) -> str: + """Return an integer sequence method result or a diagnostic error marker.""" + + try: + return str([int(value) for value in method()]) + except RuntimeError as error: + return f"unavailable ({error.__class__.__name__}: {error})" diff --git a/simple_syrup/services/segs_detection_service.py b/simple_syrup/services/segs_detection_service.py new file mode 100644 index 0000000..345b599 --- /dev/null +++ b/simple_syrup/services/segs_detection_service.py @@ -0,0 +1,245 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Service for converting detector model predictions into native SEGS.""" + +from __future__ import annotations + +from typing import Protocol + +import torch + +from ..domain.segs import NativeSegs, Segment +from ..masking.segs_mask_ops import ( + crop_image, + crop_mask, + crop_region_for_bbox, + dilate_mask, + rectangular_mask, + validate_single_image, +) +from ..runtime.ultralytics_detection import ( + UltralyticsDetection, + run_ultralytics_detection, +) +from ..runtime.ultralytics_loader import UltralyticsDetectorModel +from ..shared.logging import get_logger +from .segs_output_service import coerce_cropped_mask, combined_mask_from_segs + +LOGGER = get_logger(__name__) + + +class DetectionRunner(Protocol): + """Callable boundary that runs detector inference.""" + + def __call__( + self, + detector_model: UltralyticsDetectorModel, + image: torch.Tensor, + threshold: float, + prefer_segmentation: bool, + ) -> tuple[UltralyticsDetection, ...]: + """Return parsed detections for an image.""" + + +class SegsDetectionService: + """Build native SEGS from an Ultralytics detector model.""" + + def __init__( + self, + detection_runner: DetectionRunner = run_ultralytics_detection, + ) -> None: + """Create the service with an injectable inference boundary.""" + + self._detection_runner = detection_runner + + def detect( + self, + image: object, + detector_model: UltralyticsDetectorModel, + threshold: float, + dilation: int, + crop_factor: float, + drop_size: int, + prefer_segmentation: bool = True, + labels: str | None = "all", + post_dilation: int = 0, + ) -> NativeSegs: + """Detect image regions and return immutable native SEGS.""" + + if not 0.0 <= threshold <= 1.0: + raise ValueError("threshold must be between 0 and 1.") + if drop_size < 1: + raise ValueError("drop_size must be at least 1.") + + image_tensor = validate_single_image(image, "SEGS detector") + height = int(image_tensor.shape[1]) + width = int(image_tensor.shape[2]) + use_segmentation = prefer_segmentation and detector_model.supports_segmentation + label_filter = _parse_labels(labels) + detections = self._detection_runner( + detector_model, + image_tensor, + threshold, + use_segmentation, + ) + + segments: list[Segment] = [] + for detection in detections: + if detection.confidence < threshold: + continue + if detection.bbox.width < drop_size or detection.bbox.height < drop_size: + continue + if label_filter is not None and not _label_matches( + detection.label, + label_filter, + ): + continue + mask = detection.mask + if mask is None or not use_segmentation: + mask = rectangular_mask(height, width, detection.bbox) + mask = dilate_mask(mask, dilation).to(device=image_tensor.device) + crop_region = crop_region_for_bbox( + detection.bbox, + image_height=height, + image_width=width, + crop_factor=crop_factor, + ) + cropped_segment_mask = crop_mask(mask, crop_region).detach().clone() + if post_dilation != 0: + cropped_segment_mask = dilate_mask(cropped_segment_mask, post_dilation) + segments.append( + Segment( + cropped_image=crop_image(image_tensor, crop_region) + .detach() + .clone(), + cropped_mask=cropped_segment_mask, + confidence=detection.confidence, + crop_region=crop_region, + bbox=detection.bbox, + label=detection.label, + ) + ) + + LOGGER.debug( + "Detected SEGS", + extra={ + "operation": "detect_segs", + "model_name": detector_model.model_name, + "threshold": threshold, + "crop_factor": crop_factor, + "segment_count": len(segments), + }, + ) + return (height, width), tuple(segments) + + def detect_simple( + self, + image: object, + detector_model: UltralyticsDetectorModel, + bbox_threshold: float, + bbox_dilation: int, + crop_factor: float, + drop_size: int, + sub_threshold: float, + sub_dilation: int, + post_dilation: int = 0, + ) -> NativeSegs: + """Run Simple Detector SEGS-style bbox detection with optional refinement.""" + + bbox_segs = self.detect( + image=image, + detector_model=detector_model, + threshold=bbox_threshold, + dilation=bbox_dilation, + crop_factor=crop_factor, + drop_size=drop_size, + prefer_segmentation=False, + post_dilation=0, + ) + if detector_model.supports_segmentation: + refinement_segs = self.detect( + image=image, + detector_model=detector_model, + threshold=sub_threshold, + dilation=sub_dilation, + crop_factor=crop_factor, + drop_size=drop_size, + prefer_segmentation=True, + post_dilation=0, + ) + bbox_segs = _intersect_segs_with_combined_mask(bbox_segs, refinement_segs) + if post_dilation != 0: + bbox_segs = _dilate_cropped_segs(bbox_segs, post_dilation) + return bbox_segs + + +def _parse_labels(labels: str | None) -> set[str] | None: + """Parse an Impact-style comma-separated label allowlist.""" + + if labels is None or labels.strip() == "": + return None + parsed = {label.strip() for label in labels.split(",") if label.strip()} + if not parsed or "all" in parsed: + return None + return parsed + + +def _label_matches(label: str, labels: set[str]) -> bool: + """Return whether a segment label passes Impact-style label grouping.""" + + if label in labels: + return True + if "eyes" in labels and label in {"left_eye", "right_eye"}: + return True + if "eyebrows" in labels and label in {"left_eyebrow", "right_eyebrow"}: + return True + return "pupils" in labels and label in {"left_pupil", "right_pupil"} + + +def _intersect_segs_with_combined_mask( + segs: NativeSegs, + mask_segs: NativeSegs, +) -> NativeSegs: + """Apply the combined masks from one SEGS payload to another.""" + + header, segments = segs + combined_mask = combined_mask_from_segs(mask_segs) + refined_segments: list[Segment] = [] + for segment in segments: + cropped_mask = coerce_cropped_mask(segment) + refinement_mask = crop_mask(combined_mask, segment.crop_region) + refined_segments.append( + Segment( + cropped_image=segment.cropped_image, + cropped_mask=torch.minimum( + cropped_mask.float(), + refinement_mask.float(), + ), + confidence=segment.confidence, + crop_region=segment.crop_region, + bbox=segment.bbox, + label=segment.label, + control_net_wrapper=segment.control_net_wrapper, + ) + ) + return header, tuple(refined_segments) + + +def _dilate_cropped_segs(segs: NativeSegs, dilation: int) -> NativeSegs: + """Apply signed morphology to each cropped segment mask.""" + + header, segments = segs + return header, tuple( + Segment( + cropped_image=segment.cropped_image, + cropped_mask=dilate_mask(coerce_cropped_mask(segment), dilation), + confidence=segment.confidence, + crop_region=segment.crop_region, + bbox=segment.bbox, + label=segment.label, + control_net_wrapper=segment.control_net_wrapper, + ) + for segment in segments + ) diff --git a/simple_syrup/services/segs_output_service.py b/simple_syrup/services/segs_output_service.py new file mode 100644 index 0000000..584d621 --- /dev/null +++ b/simple_syrup/services/segs_output_service.py @@ -0,0 +1,92 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Shared helpers for SEGS detector node outputs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import torch + +from ..domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from ..masking.segs_mask_ops import crop_image, crop_mask, validate_single_image + + +@dataclass(frozen=True) +class CombinedSegsResult: + """Return one combined SEGS payload and its full-image mask.""" + + segs: NativeSegs + mask: torch.Tensor + + +def build_combined_segs_result( + image: object, + segs: NativeSegs, +) -> CombinedSegsResult: + """Build one combined SEGS payload and a standard ComfyUI mask.""" + + image_tensor = validate_single_image(image, "SEGS detector") + header, segments = segs + height, width = header + combined_mask = combined_mask_from_segs(segs) + mask_output = combined_mask.unsqueeze(0).to(dtype=torch.float32, device="cpu") + if not segments or not torch.any(combined_mask > 0): + return CombinedSegsResult(segs=(header, ()), mask=mask_output) + + y_coords, x_coords = torch.where(combined_mask > 0) + left = int(torch.min(x_coords).item()) + top = int(torch.min(y_coords).item()) + right = min(width, int(torch.max(x_coords).item()) + 1) + bottom = min(height, int(torch.max(y_coords).item()) + 1) + if right <= left or bottom <= top: + return CombinedSegsResult(segs=(header, ()), mask=mask_output) + + crop_region = CropRegion(left, top, right, bottom) + cropped_mask = crop_mask(combined_mask, crop_region).detach().clone() + combined_segment = Segment( + cropped_image=crop_image(image_tensor, crop_region).detach().clone(), + cropped_mask=cropped_mask, + confidence=max(segment.confidence for segment in segments), + crop_region=crop_region, + bbox=BoundingBox(left, top, right, bottom), + label="combined", + ) + return CombinedSegsResult(segs=(header, (combined_segment,)), mask=mask_output) + + +def combined_mask_from_segs(segs: NativeSegs) -> torch.Tensor: + """Combine cropped segment masks into one full-image HW mask.""" + + header, segments = segs + height, width = header + mask = torch.zeros((height, width), dtype=torch.float32) + for segment in segments: + cropped_mask = coerce_cropped_mask(segment) + region = segment.crop_region + existing = mask[region.top : region.bottom, region.left : region.right] + mask[region.top : region.bottom, region.left : region.right] = torch.maximum( + existing, + cropped_mask.float().cpu(), + ) + return mask.clamp(0.0, 1.0) + + +def coerce_cropped_mask(segment: Segment) -> torch.Tensor: + """Return a crop-local HW mask tensor for a segment.""" + + if isinstance(segment.cropped_mask, torch.Tensor): + cropped_mask = segment.cropped_mask.float() + else: + cropped_mask = torch.as_tensor(segment.cropped_mask, dtype=torch.float32) + if cropped_mask.ndim == 3 and int(cropped_mask.shape[0]) == 1: + cropped_mask = cropped_mask.squeeze(0) + if cropped_mask.ndim != 2: + raise ValueError("Segment cropped_mask must be HW shaped.") + expected_shape = (segment.crop_region.height, segment.crop_region.width) + actual_shape = (int(cropped_mask.shape[0]), int(cropped_mask.shape[1])) + if actual_shape != expected_shape: + raise ValueError("Segment cropped_mask must match its crop region.") + return cropped_mask.clamp(0.0, 1.0) diff --git a/simple_syrup/services/tile_and_tag_segs_service.py b/simple_syrup/services/tile_and_tag_segs_service.py new file mode 100644 index 0000000..80543c0 --- /dev/null +++ b/simple_syrup/services/tile_and_tag_segs_service.py @@ -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 + +"""Application service for Tile & Tag SEGS orchestration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Protocol + +import torch + +from ..domain.conditioning_batch import ConditioningBatch +from ..domain.prompt_composition import prefix_prompt +from ..domain.segs import ImpactSegs, NativeSegs, to_impact_compatible_segs +from ..domain.tile_segs import TileSEGSBuilder, TileSEGSControls +from ..masking.segs_mask_ops import crop_image, validate_single_image +from ..runtime.conditioning_encoding import ComfyConditioningEncoder +from ..runtime.loaded_models import LoadedWD14Tagger, unwrap_wd14_tagger +from ..runtime.progress import ProgressReporter, create_comfy_progress +from ..runtime.wd14_tagger import WD14TagFormattingControls, WD14Tagger +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + + +class TileSEGSBuildingBoundary(Protocol): + """Build tile SEGS for an image.""" + + def build( + self, + image: torch.Tensor, + controls: TileSEGSControls, + ) -> NativeSegs: + """Return native SEGS for ordered tiles.""" + + +class WD14TaggingBoundary(Protocol): + """Tag ordered image crops.""" + + def tag_images( + self, + loaded_tagger: LoadedWD14Tagger, + images: tuple[torch.Tensor, ...], + controls: WD14TagFormattingControls, + progress: ProgressReporter | None = None, + ) -> tuple[str, ...]: + """Return one tag string per image in input order.""" + + +class ConditioningEncodingBoundary(Protocol): + """Encode ordered prompts into a conditioning batch.""" + + def encode_batch(self, clip: Any, chunks: tuple[str, ...]) -> ConditioningBatch: + """Return conditioning entries in prompt order.""" + + +@dataclass(frozen=True) +class TileAndTagSEGSResult: + """Return tile SEGS and aligned prompt conditioning.""" + + segs: ImpactSegs + positive: ConditioningBatch + + +class TileAndTagSEGSService: + """Create tile SEGS and aligned WD14 conditioning for detailing.""" + + def __init__( + self, + tiler: TileSEGSBuildingBoundary | None = None, + tagger: WD14TaggingBoundary | None = None, + encoder: ConditioningEncodingBoundary | None = None, + progress_factory: Callable[[int], ProgressReporter] | None = None, + ) -> None: + """Create the service with injectable collaborators for tests.""" + + self._tiler = tiler or TileSEGSBuilder() + self._tagger = tagger or WD14Tagger() + self._encoder = encoder or ComfyConditioningEncoder() + self._progress_factory = progress_factory or create_comfy_progress + + def tile_and_tag( + self, + image: object, + clip: Any, + wd14_tagger: object, + tile_controls: TileSEGSControls, + tag_controls: WD14TagFormattingControls, + universal_positive: str, + ) -> TileAndTagSEGSResult: + """Return ordered tile SEGS and aligned positive conditioning.""" + + image_tensor = validate_single_image(image, "Tile & Tag SEGS") + loaded_tagger = unwrap_wd14_tagger(wd14_tagger) + native_segs = self._tiler.build( + image_tensor, + tile_controls, + ) + _header, segments = native_segs + if not segments: + raise ValueError("No tile SEGS were generated for Tile & Tag SEGS.") + progress = self._progress_factory(len(segments) + 2) + progress.update(1) + crops = tuple( + crop_image(image_tensor, segment.crop_region) for segment in segments + ) + tags = self._tagger.tag_images( + loaded_tagger, + crops, + tag_controls, + progress=progress, + ) + if len(tags) != len(segments): + raise ValueError( + "WD14 tagger returned " + f"{len(tags)} tag(s) for {len(segments)} tile SEGS." + ) + prompts = tuple(prefix_prompt(universal_positive, tag) for tag in tags) + positive = self._encoder.encode_batch(clip, prompts) + progress.update(1) + if len(positive.entries) != len(segments): + raise ValueError( + "Conditioning encoder returned " + f"{len(positive.entries)} entries for {len(segments)} tile SEGS." + ) + LOGGER.info( + "Tile & Tag SEGS pass completed", + extra={ + "operation": "tile_and_tag_segs", + "segment_count": len(segments), + "wd14_model": loaded_tagger.model_id, + "threshold": tag_controls.threshold, + "character_threshold": tag_controls.character_threshold, + "universal_positive_present": bool(universal_positive.strip()), + }, + ) + return TileAndTagSEGSResult( + segs=to_impact_compatible_segs(native_segs), + positive=positive, + ) diff --git a/simple_syrup/services/tiled_diffusion_sampling_service.py b/simple_syrup/services/tiled_diffusion_sampling_service.py new file mode 100644 index 0000000..a467cd8 --- /dev/null +++ b/simple_syrup/services/tiled_diffusion_sampling_service.py @@ -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 + +"""Application service for selectable tiled diffusion latent sampling.""" + +from __future__ import annotations + +from typing import Any + +from ..domain.tiled_diffusion import validate_tiled_diffusion_mode +from ..runtime import mixture_of_diffusers_sampling, multidiffusion_sampling +from ..runtime.detail_previews import DetailPreviewContext + +Latent = dict[str, Any] + + +class TiledDiffusionSamplingService: + """Route tiled diffusion sampling requests to the selected runtime.""" + + def sample( + self, + *, + diffusion_mode: str, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Sample a latent with the selected tiled diffusion method.""" + + validate_tiled_diffusion_mode(diffusion_mode) + if diffusion_mode == "multidiffusion": + return multidiffusion_sampling.sample_multidiffusion( + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=preview_context, + ) + return mixture_of_diffusers_sampling.sample_mixture_of_diffusers( + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=preview_context, + ) diff --git a/simple_syrup/shared/__init__.py b/simple_syrup/shared/__init__.py new file mode 100644 index 0000000..a1b3d66 --- /dev/null +++ b/simple_syrup/shared/__init__.py @@ -0,0 +1,9 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Shared primitives for SimpleSyrup modules.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/simple_syrup/shared/logging.py b/simple_syrup/shared/logging.py new file mode 100644 index 0000000..295bcd0 --- /dev/null +++ b/simple_syrup/shared/logging.py @@ -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 + +"""Logging helpers for SimpleSyrup modules.""" + +from __future__ import annotations + +import logging + + +def get_logger(name: str) -> logging.Logger: + """Return a SimpleSyrup child logger without configuring global logging.""" + + return logging.getLogger(f"simple_syrup.{name}") diff --git a/simple_syrup/shared/tensor_validation.py b/simple_syrup/shared/tensor_validation.py new file mode 100644 index 0000000..d27d95e --- /dev/null +++ b/simple_syrup/shared/tensor_validation.py @@ -0,0 +1,84 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tensor shape validation for ComfyUI image and mask inputs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import torch + + +@dataclass(frozen=True) +class ImageTensorShape: + """Validated ComfyUI IMAGE tensor dimensions.""" + + batch_size: int + height: int + width: int + channels: int + + +@dataclass(frozen=True) +class MaskTensorShape: + """Validated ComfyUI MASK tensor dimensions.""" + + batch_size: int + height: int + width: int + + +def validate_image_tensor(image: object) -> ImageTensorShape: + """Validate a BHWC floating-point ComfyUI image tensor.""" + + if not isinstance(image, torch.Tensor): + raise TypeError("image must be a torch.Tensor with shape (B, H, W, C).") + if image.ndim != 4: + raise ValueError( + f"image must have shape (B, H, W, C), got {tuple(image.shape)}." + ) + if not image.is_floating_point(): + raise TypeError("image must be a floating-point tensor with values in [0, 1].") + + batch_size, height, width, channels = (int(value) for value in image.shape) + if batch_size <= 0 or height <= 0 or width <= 0: + raise ValueError( + "image dimensions must be positive, got " + f"batch={batch_size}, height={height}, width={width}." + ) + if channels not in (1, 3, 4): + raise ValueError( + f"Unsupported image channel count {channels}. Expected 1, 3, or 4." + ) + + return ImageTensorShape( + batch_size=batch_size, + height=height, + width=width, + channels=channels, + ) + + +def validate_mask_tensor(mask: object, batch_size: int) -> MaskTensorShape: + """Validate a BHW floating-point ComfyUI mask tensor.""" + + if not isinstance(mask, torch.Tensor): + raise TypeError("mask must be a torch.Tensor with shape (B, H, W).") + if mask.ndim != 3: + raise ValueError(f"mask must have shape (B, H, W), got {tuple(mask.shape)}.") + if not mask.is_floating_point(): + raise TypeError("mask must be a floating-point tensor with values in [0, 1].") + + mask_batch, height, width = (int(value) for value in mask.shape) + if mask_batch != batch_size: + raise ValueError( + f"mask batch size {mask_batch} must match image batch size {batch_size}." + ) + if height <= 0 or width <= 0: + raise ValueError( + f"mask dimensions must be positive, got height={height}, width={width}." + ) + + return MaskTensorShape(batch_size=mask_batch, height=height, width=width) diff --git a/simple_syrup/third_party/__init__.py b/simple_syrup/third_party/__init__.py new file mode 100644 index 0000000..44adc1a --- /dev/null +++ b/simple_syrup/third_party/__init__.py @@ -0,0 +1 @@ +"""Vendored third-party runtime components used by SimpleSyrup.""" diff --git a/simple_syrup/third_party/groundingdino_runtime/__init__.py b/simple_syrup/third_party/groundingdino_runtime/__init__.py new file mode 100644 index 0000000..fd8775f --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/__init__.py @@ -0,0 +1 @@ +"""SimpleSyrup-owned GroundingDINO runtime package.""" diff --git a/simple_syrup/third_party/groundingdino_runtime/datasets/__init__.py b/simple_syrup/third_party/groundingdino_runtime/datasets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simple_syrup/third_party/groundingdino_runtime/datasets/transforms.py b/simple_syrup/third_party/groundingdino_runtime/datasets/transforms.py new file mode 100644 index 0000000..d80ac11 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/datasets/transforms.py @@ -0,0 +1,312 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved +""" +Transforms and data augmentation for both image + bbox. +""" +import os +import random + +import PIL +import torch +import torchvision.transforms as T +import torchvision.transforms.functional as F + +from simple_syrup.third_party.groundingdino_runtime.util.box_ops import box_xyxy_to_cxcywh +from simple_syrup.third_party.groundingdino_runtime.util.misc import interpolate + + +def crop(image, target, region): + cropped_image = F.crop(image, *region) + + target = target.copy() + i, j, h, w = region + + # should we do something wrt the original size? + target["size"] = torch.tensor([h, w]) + + fields = ["labels", "area", "iscrowd", "positive_map"] + + if "boxes" in target: + boxes = target["boxes"] + max_size = torch.as_tensor([w, h], dtype=torch.float32) + cropped_boxes = boxes - torch.as_tensor([j, i, j, i]) + cropped_boxes = torch.min(cropped_boxes.reshape(-1, 2, 2), max_size) + cropped_boxes = cropped_boxes.clamp(min=0) + area = (cropped_boxes[:, 1, :] - cropped_boxes[:, 0, :]).prod(dim=1) + target["boxes"] = cropped_boxes.reshape(-1, 4) + target["area"] = area + fields.append("boxes") + + if "masks" in target: + # FIXME should we update the area here if there are no boxes? + target["masks"] = target["masks"][:, i : i + h, j : j + w] + fields.append("masks") + + # remove elements for which the boxes or masks that have zero area + if "boxes" in target or "masks" in target: + # favor boxes selection when defining which elements to keep + # this is compatible with previous implementation + if "boxes" in target: + cropped_boxes = target["boxes"].reshape(-1, 2, 2) + keep = torch.all(cropped_boxes[:, 1, :] > cropped_boxes[:, 0, :], dim=1) + else: + keep = target["masks"].flatten(1).any(1) + + for field in fields: + if field in target: + target[field] = target[field][keep] + + if os.environ.get("IPDB_SHILONG_DEBUG", None) == "INFO": + # for debug and visualization only. + if "strings_positive" in target: + target["strings_positive"] = [ + _i for _i, _j in zip(target["strings_positive"], keep) if _j + ] + + return cropped_image, target + + +def hflip(image, target): + flipped_image = F.hflip(image) + + w, h = image.size + + target = target.copy() + if "boxes" in target: + boxes = target["boxes"] + boxes = boxes[:, [2, 1, 0, 3]] * torch.as_tensor([-1, 1, -1, 1]) + torch.as_tensor( + [w, 0, w, 0] + ) + target["boxes"] = boxes + + if "masks" in target: + target["masks"] = target["masks"].flip(-1) + + return flipped_image, target + + +def resize(image, target, size, max_size=None): + # size can be min_size (scalar) or (w, h) tuple + + def get_size_with_aspect_ratio(image_size, size, max_size=None): + w, h = image_size + if max_size is not None: + min_original_size = float(min((w, h))) + max_original_size = float(max((w, h))) + if max_original_size / min_original_size * size > max_size: + size = int(round(max_size * min_original_size / max_original_size)) + + if (w <= h and w == size) or (h <= w and h == size): + return (h, w) + + if w < h: + ow = size + oh = int(size * h / w) + else: + oh = size + ow = int(size * w / h) + + return (oh, ow) + + def get_size(image_size, size, max_size=None): + if isinstance(size, (list, tuple)): + return size[::-1] + else: + return get_size_with_aspect_ratio(image_size, size, max_size) + + size = get_size(image.size, size, max_size) + rescaled_image = F.resize(image, size) + + if target is None: + return rescaled_image, None + + ratios = tuple(float(s) / float(s_orig) for s, s_orig in zip(rescaled_image.size, image.size)) + ratio_width, ratio_height = ratios + + target = target.copy() + if "boxes" in target: + boxes = target["boxes"] + scaled_boxes = boxes * torch.as_tensor( + [ratio_width, ratio_height, ratio_width, ratio_height] + ) + target["boxes"] = scaled_boxes + + if "area" in target: + area = target["area"] + scaled_area = area * (ratio_width * ratio_height) + target["area"] = scaled_area + + h, w = size + target["size"] = torch.tensor([h, w]) + + if "masks" in target: + target["masks"] = ( + interpolate(target["masks"][:, None].float(), size, mode="nearest")[:, 0] > 0.5 + ) + + return rescaled_image, target + + +def pad(image, target, padding): + # assumes that we only pad on the bottom right corners + padded_image = F.pad(image, (0, 0, padding[0], padding[1])) + if target is None: + return padded_image, None + target = target.copy() + # should we do something wrt the original size? + target["size"] = torch.tensor(padded_image.size[::-1]) + if "masks" in target: + target["masks"] = torch.nn.functional.pad(target["masks"], (0, padding[0], 0, padding[1])) + return padded_image, target + + +class ResizeDebug(object): + def __init__(self, size): + self.size = size + + def __call__(self, img, target): + return resize(img, target, self.size) + + +class RandomCrop(object): + def __init__(self, size): + self.size = size + + def __call__(self, img, target): + region = T.RandomCrop.get_params(img, self.size) + return crop(img, target, region) + + +class RandomSizeCrop(object): + def __init__(self, min_size: int, max_size: int, respect_boxes: bool = False): + # respect_boxes: True to keep all boxes + # False to tolerence box filter + self.min_size = min_size + self.max_size = max_size + self.respect_boxes = respect_boxes + + def __call__(self, img: PIL.Image.Image, target: dict): + init_boxes = len(target["boxes"]) + max_patience = 10 + for i in range(max_patience): + w = random.randint(self.min_size, min(img.width, self.max_size)) + h = random.randint(self.min_size, min(img.height, self.max_size)) + region = T.RandomCrop.get_params(img, [h, w]) + result_img, result_target = crop(img, target, region) + if ( + not self.respect_boxes + or len(result_target["boxes"]) == init_boxes + or i == max_patience - 1 + ): + return result_img, result_target + return result_img, result_target + + +class CenterCrop(object): + def __init__(self, size): + self.size = size + + def __call__(self, img, target): + image_width, image_height = img.size + crop_height, crop_width = self.size + crop_top = int(round((image_height - crop_height) / 2.0)) + crop_left = int(round((image_width - crop_width) / 2.0)) + return crop(img, target, (crop_top, crop_left, crop_height, crop_width)) + + +class RandomHorizontalFlip(object): + def __init__(self, p=0.5): + self.p = p + + def __call__(self, img, target): + if random.random() < self.p: + return hflip(img, target) + return img, target + + +class RandomResize(object): + def __init__(self, sizes, max_size=None): + assert isinstance(sizes, (list, tuple)) + self.sizes = sizes + self.max_size = max_size + + def __call__(self, img, target=None): + size = random.choice(self.sizes) + return resize(img, target, size, self.max_size) + + +class RandomPad(object): + def __init__(self, max_pad): + self.max_pad = max_pad + + def __call__(self, img, target): + pad_x = random.randint(0, self.max_pad) + pad_y = random.randint(0, self.max_pad) + return pad(img, target, (pad_x, pad_y)) + + +class RandomSelect(object): + """ + Randomly selects between transforms1 and transforms2, + with probability p for transforms1 and (1 - p) for transforms2 + """ + + def __init__(self, transforms1, transforms2, p=0.5): + self.transforms1 = transforms1 + self.transforms2 = transforms2 + self.p = p + + def __call__(self, img, target): + if random.random() < self.p: + return self.transforms1(img, target) + return self.transforms2(img, target) + + +class ToTensor(object): + def __call__(self, img, target): + return F.to_tensor(img), target + + +class RandomErasing(object): + def __init__(self, *args, **kwargs): + self.eraser = T.RandomErasing(*args, **kwargs) + + def __call__(self, img, target): + return self.eraser(img), target + + +class Normalize(object): + def __init__(self, mean, std): + self.mean = mean + self.std = std + + def __call__(self, image, target=None): + image = F.normalize(image, mean=self.mean, std=self.std) + if target is None: + return image, None + target = target.copy() + h, w = image.shape[-2:] + if "boxes" in target: + boxes = target["boxes"] + boxes = box_xyxy_to_cxcywh(boxes) + boxes = boxes / torch.tensor([w, h, w, h], dtype=torch.float32) + target["boxes"] = boxes + return image, target + + +class Compose(object): + def __init__(self, transforms): + self.transforms = transforms + + def __call__(self, image, target): + for t in self.transforms: + image, target = t(image, target) + return image, target + + def __repr__(self): + format_string = self.__class__.__name__ + "(" + for t in self.transforms: + format_string += "\n" + format_string += " {0}".format(t) + format_string += "\n)" + return format_string + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/__init__.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/__init__.py new file mode 100644 index 0000000..51db6ce --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/__init__.py @@ -0,0 +1,16 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Conditional DETR +# Copyright (c) 2021 Microsoft. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Copied from DETR (https://github.com/facebookresearch/detr) +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. +# ------------------------------------------------------------------------ + +from .groundingdino import build_groundingdino + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/__init__.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/__init__.py new file mode 100644 index 0000000..d890a4b --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/__init__.py @@ -0,0 +1,2 @@ +from .backbone import build_backbone + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/backbone.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/backbone.py new file mode 100644 index 0000000..25e1024 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/backbone.py @@ -0,0 +1,222 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Conditional DETR +# Copyright (c) 2021 Microsoft. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Copied from DETR (https://github.com/facebookresearch/detr) +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. +# ------------------------------------------------------------------------ + +""" +Backbone modules. +""" + +from typing import Dict, List + +import torch +import torch.nn.functional as F +import torchvision +from torch import nn +from torchvision.models._utils import IntermediateLayerGetter + +from simple_syrup.third_party.groundingdino_runtime.util.misc import NestedTensor, is_main_process + +from .position_encoding import build_position_encoding +from .swin_transformer import build_swin_transformer + + +class FrozenBatchNorm2d(torch.nn.Module): + """ + BatchNorm2d where the batch statistics and the affine parameters are fixed. + + Copy-paste from torchvision.misc.ops with added eps before rqsrt, + without which any other models than torchvision.models.resnet[18,34,50,101] + produce nans. + """ + + def __init__(self, n): + super(FrozenBatchNorm2d, self).__init__() + self.register_buffer("weight", torch.ones(n)) + self.register_buffer("bias", torch.zeros(n)) + self.register_buffer("running_mean", torch.zeros(n)) + self.register_buffer("running_var", torch.ones(n)) + + def _load_from_state_dict( + self, state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs + ): + num_batches_tracked_key = prefix + "num_batches_tracked" + if num_batches_tracked_key in state_dict: + del state_dict[num_batches_tracked_key] + + super(FrozenBatchNorm2d, self)._load_from_state_dict( + state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs + ) + + def forward(self, x): + # move reshapes to the beginning + # to make it fuser-friendly + w = self.weight.reshape(1, -1, 1, 1) + b = self.bias.reshape(1, -1, 1, 1) + rv = self.running_var.reshape(1, -1, 1, 1) + rm = self.running_mean.reshape(1, -1, 1, 1) + eps = 1e-5 + scale = w * (rv + eps).rsqrt() + bias = b - rm * scale + return x * scale + bias + + +class BackboneBase(nn.Module): + def __init__( + self, + backbone: nn.Module, + train_backbone: bool, + num_channels: int, + return_interm_indices: list, + ): + super().__init__() + for name, parameter in backbone.named_parameters(): + if ( + not train_backbone + or "layer2" not in name + and "layer3" not in name + and "layer4" not in name + ): + parameter.requires_grad_(False) + + return_layers = {} + for idx, layer_index in enumerate(return_interm_indices): + return_layers.update( + {"layer{}".format(5 - len(return_interm_indices) + idx): "{}".format(layer_index)} + ) + + # if len: + # if use_stage1_feature: + # return_layers = {"layer1": "0", "layer2": "1", "layer3": "2", "layer4": "3"} + # else: + # return_layers = {"layer2": "0", "layer3": "1", "layer4": "2"} + # else: + # return_layers = {'layer4': "0"} + self.body = IntermediateLayerGetter(backbone, return_layers=return_layers) + self.num_channels = num_channels + + def forward(self, tensor_list: NestedTensor): + xs = self.body(tensor_list.tensors) + out: Dict[str, NestedTensor] = {} + for name, x in xs.items(): + m = tensor_list.mask + assert m is not None + mask = F.interpolate(m[None].float(), size=x.shape[-2:]).to(torch.bool)[0] + out[name] = NestedTensor(x, mask) + # import ipdb; ipdb.set_trace() + return out + + +class Backbone(BackboneBase): + """ResNet backbone with frozen BatchNorm.""" + + def __init__( + self, + name: str, + train_backbone: bool, + dilation: bool, + return_interm_indices: list, + batch_norm=FrozenBatchNorm2d, + ): + if name in ["resnet18", "resnet34", "resnet50", "resnet101"]: + backbone = getattr(torchvision.models, name)( + replace_stride_with_dilation=[False, False, dilation], + pretrained=is_main_process(), + norm_layer=batch_norm, + ) + else: + raise NotImplementedError("Why you can get here with name {}".format(name)) + # num_channels = 512 if name in ('resnet18', 'resnet34') else 2048 + assert name not in ("resnet18", "resnet34"), "Only resnet50 and resnet101 are available." + assert return_interm_indices in [[0, 1, 2, 3], [1, 2, 3], [3]] + num_channels_all = [256, 512, 1024, 2048] + num_channels = num_channels_all[4 - len(return_interm_indices) :] + super().__init__(backbone, train_backbone, num_channels, return_interm_indices) + + +class Joiner(nn.Sequential): + def __init__(self, backbone, position_embedding): + super().__init__(backbone, position_embedding) + + def forward(self, tensor_list: NestedTensor): + xs = self[0](tensor_list) + out: List[NestedTensor] = [] + pos = [] + for name, x in xs.items(): + out.append(x) + # position encoding + pos.append(self[1](x).to(x.tensors.dtype)) + + return out, pos + + +def build_backbone(args): + """ + Useful args: + - backbone: backbone name + - lr_backbone: + - dilation + - return_interm_indices: available: [0,1,2,3], [1,2,3], [3] + - backbone_freeze_keywords: + - use_checkpoint: for swin only for now + + """ + position_embedding = build_position_encoding(args) + train_backbone = True + if not train_backbone: + raise ValueError("Please set lr_backbone > 0") + return_interm_indices = args.return_interm_indices + assert return_interm_indices in [[0, 1, 2, 3], [1, 2, 3], [3]] + args.backbone_freeze_keywords + use_checkpoint = getattr(args, "use_checkpoint", False) + + if args.backbone in ["resnet50", "resnet101"]: + backbone = Backbone( + args.backbone, + train_backbone, + args.dilation, + return_interm_indices, + batch_norm=FrozenBatchNorm2d, + ) + bb_num_channels = backbone.num_channels + elif args.backbone in [ + "swin_T_224_1k", + "swin_B_224_22k", + "swin_B_384_22k", + "swin_L_224_22k", + "swin_L_384_22k", + ]: + pretrain_img_size = int(args.backbone.split("_")[-2]) + backbone = build_swin_transformer( + args.backbone, + pretrain_img_size=pretrain_img_size, + out_indices=tuple(return_interm_indices), + dilation=False, + use_checkpoint=use_checkpoint, + ) + + bb_num_channels = backbone.num_features[4 - len(return_interm_indices) :] + else: + raise NotImplementedError("Unknown backbone {}".format(args.backbone)) + + assert len(bb_num_channels) == len( + return_interm_indices + ), f"len(bb_num_channels) {len(bb_num_channels)} != len(return_interm_indices) {len(return_interm_indices)}" + + model = Joiner(backbone, position_embedding) + model.num_channels = bb_num_channels + assert isinstance( + bb_num_channels, List + ), "bb_num_channels is expected to be a List but {}".format(type(bb_num_channels)) + # import ipdb; ipdb.set_trace() + return model + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/position_encoding.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/position_encoding.py new file mode 100644 index 0000000..874b07d --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/position_encoding.py @@ -0,0 +1,187 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# DINO +# Copyright (c) 2022 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Conditional DETR +# Copyright (c) 2021 Microsoft. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Copied from DETR (https://github.com/facebookresearch/detr) +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. +# ------------------------------------------------------------------------ + +""" +Various positional encodings for the transformer. +""" +import math + +import torch +from torch import nn + +from simple_syrup.third_party.groundingdino_runtime.util.misc import NestedTensor + + +class PositionEmbeddingSine(nn.Module): + """ + This is a more standard version of the position embedding, very similar to the one + used by the Attention is all you need paper, generalized to work on images. + """ + + def __init__(self, num_pos_feats=64, temperature=10000, normalize=False, scale=None): + super().__init__() + self.num_pos_feats = num_pos_feats + self.temperature = temperature + self.normalize = normalize + if scale is not None and normalize is False: + raise ValueError("normalize should be True if scale is passed") + if scale is None: + scale = 2 * math.pi + self.scale = scale + + def forward(self, tensor_list: NestedTensor): + x = tensor_list.tensors + mask = tensor_list.mask + assert mask is not None + not_mask = ~mask + y_embed = not_mask.cumsum(1, dtype=torch.float32) + x_embed = not_mask.cumsum(2, dtype=torch.float32) + if self.normalize: + eps = 1e-6 + # if os.environ.get("SHILONG_AMP", None) == '1': + # eps = 1e-4 + # else: + # eps = 1e-6 + y_embed = y_embed / (y_embed[:, -1:, :] + eps) * self.scale + x_embed = x_embed / (x_embed[:, :, -1:] + eps) * self.scale + + dim_t = torch.arange(self.num_pos_feats, dtype=torch.float32, device=x.device) + dim_t = self.temperature ** (2 * (dim_t // 2) / self.num_pos_feats) + + pos_x = x_embed[:, :, :, None] / dim_t + pos_y = y_embed[:, :, :, None] / dim_t + pos_x = torch.stack( + (pos_x[:, :, :, 0::2].sin(), pos_x[:, :, :, 1::2].cos()), dim=4 + ).flatten(3) + pos_y = torch.stack( + (pos_y[:, :, :, 0::2].sin(), pos_y[:, :, :, 1::2].cos()), dim=4 + ).flatten(3) + pos = torch.cat((pos_y, pos_x), dim=3).permute(0, 3, 1, 2) + return pos + + +class PositionEmbeddingSineHW(nn.Module): + """ + This is a more standard version of the position embedding, very similar to the one + used by the Attention is all you need paper, generalized to work on images. + """ + + def __init__( + self, num_pos_feats=64, temperatureH=10000, temperatureW=10000, normalize=False, scale=None + ): + super().__init__() + self.num_pos_feats = num_pos_feats + self.temperatureH = temperatureH + self.temperatureW = temperatureW + self.normalize = normalize + if scale is not None and normalize is False: + raise ValueError("normalize should be True if scale is passed") + if scale is None: + scale = 2 * math.pi + self.scale = scale + + def forward(self, tensor_list: NestedTensor): + x = tensor_list.tensors + mask = tensor_list.mask + assert mask is not None + not_mask = ~mask + y_embed = not_mask.cumsum(1, dtype=torch.float32) + x_embed = not_mask.cumsum(2, dtype=torch.float32) + + # import ipdb; ipdb.set_trace() + + if self.normalize: + eps = 1e-6 + y_embed = y_embed / (y_embed[:, -1:, :] + eps) * self.scale + x_embed = x_embed / (x_embed[:, :, -1:] + eps) * self.scale + + dim_tx = torch.arange(self.num_pos_feats, dtype=torch.float32, device=x.device) + dim_tx = self.temperatureW ** (2 * (torch.div(dim_tx, 2, rounding_mode='floor')) / self.num_pos_feats) + pos_x = x_embed[:, :, :, None] / dim_tx + + dim_ty = torch.arange(self.num_pos_feats, dtype=torch.float32, device=x.device) + dim_ty = self.temperatureH ** (2 * (torch.div(dim_ty, 2, rounding_mode='floor')) / self.num_pos_feats) + pos_y = y_embed[:, :, :, None] / dim_ty + + pos_x = torch.stack( + (pos_x[:, :, :, 0::2].sin(), pos_x[:, :, :, 1::2].cos()), dim=4 + ).flatten(3) + pos_y = torch.stack( + (pos_y[:, :, :, 0::2].sin(), pos_y[:, :, :, 1::2].cos()), dim=4 + ).flatten(3) + pos = torch.cat((pos_y, pos_x), dim=3).permute(0, 3, 1, 2) + + # import ipdb; ipdb.set_trace() + + return pos + + +class PositionEmbeddingLearned(nn.Module): + """ + Absolute pos embedding, learned. + """ + + def __init__(self, num_pos_feats=256): + super().__init__() + self.row_embed = nn.Embedding(50, num_pos_feats) + self.col_embed = nn.Embedding(50, num_pos_feats) + self.reset_parameters() + + def reset_parameters(self): + nn.init.uniform_(self.row_embed.weight) + nn.init.uniform_(self.col_embed.weight) + + def forward(self, tensor_list: NestedTensor): + x = tensor_list.tensors + h, w = x.shape[-2:] + i = torch.arange(w, device=x.device) + j = torch.arange(h, device=x.device) + x_emb = self.col_embed(i) + y_emb = self.row_embed(j) + pos = ( + torch.cat( + [ + x_emb.unsqueeze(0).repeat(h, 1, 1), + y_emb.unsqueeze(1).repeat(1, w, 1), + ], + dim=-1, + ) + .permute(2, 0, 1) + .unsqueeze(0) + .repeat(x.shape[0], 1, 1, 1) + ) + return pos + + +def build_position_encoding(args): + N_steps = args.hidden_dim // 2 + if args.position_embedding in ("v2", "sine"): + # TODO find a better way of exposing other arguments + position_embedding = PositionEmbeddingSineHW( + N_steps, + temperatureH=args.pe_temperatureH, + temperatureW=args.pe_temperatureW, + normalize=True, + ) + elif args.position_embedding in ("v3", "learned"): + position_embedding = PositionEmbeddingLearned(N_steps) + else: + raise ValueError(f"not supported {args.position_embedding}") + + return position_embedding + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/swin_transformer.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/swin_transformer.py new file mode 100644 index 0000000..cc6a866 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/swin_transformer.py @@ -0,0 +1,803 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# DINO +# Copyright (c) 2022 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# -------------------------------------------------------- +# modified from https://github.com/SwinTransformer/Swin-Transformer-Object-Detection/blob/master/mmdet/models/backbones/swin_transformer.py +# -------------------------------------------------------- + +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F +import torch.utils.checkpoint as checkpoint +from timm.models.layers import DropPath, to_2tuple, trunc_normal_ + +from simple_syrup.third_party.groundingdino_runtime.util.misc import NestedTensor + + +class Mlp(nn.Module): + """Multilayer perceptron.""" + + def __init__( + self, in_features, hidden_features=None, out_features=None, act_layer=nn.GELU, drop=0.0 + ): + super().__init__() + out_features = out_features or in_features + hidden_features = hidden_features or in_features + self.fc1 = nn.Linear(in_features, hidden_features) + self.act = act_layer() + self.fc2 = nn.Linear(hidden_features, out_features) + self.drop = nn.Dropout(drop) + + def forward(self, x): + x = self.fc1(x) + x = self.act(x) + x = self.drop(x) + x = self.fc2(x) + x = self.drop(x) + return x + + +def window_partition(x, window_size): + """ + Args: + x: (B, H, W, C) + window_size (int): window size + Returns: + windows: (num_windows*B, window_size, window_size, C) + """ + B, H, W, C = x.shape + x = x.view(B, H // window_size, window_size, W // window_size, window_size, C) + windows = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(-1, window_size, window_size, C) + return windows + + +def window_reverse(windows, window_size, H, W): + """ + Args: + windows: (num_windows*B, window_size, window_size, C) + window_size (int): Window size + H (int): Height of image + W (int): Width of image + Returns: + x: (B, H, W, C) + """ + B = int(windows.shape[0] / (H * W / window_size / window_size)) + x = windows.view(B, H // window_size, W // window_size, window_size, window_size, -1) + x = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(B, H, W, -1) + return x + + +class WindowAttention(nn.Module): + """Window based multi-head self attention (W-MSA) module with relative position bias. + It supports both of shifted and non-shifted window. + Args: + dim (int): Number of input channels. + window_size (tuple[int]): The height and width of the window. + num_heads (int): Number of attention heads. + qkv_bias (bool, optional): If True, add a learnable bias to query, key, value. Default: True + qk_scale (float | None, optional): Override default qk scale of head_dim ** -0.5 if set + attn_drop (float, optional): Dropout ratio of attention weight. Default: 0.0 + proj_drop (float, optional): Dropout ratio of output. Default: 0.0 + """ + + def __init__( + self, + dim, + window_size, + num_heads, + qkv_bias=True, + qk_scale=None, + attn_drop=0.0, + proj_drop=0.0, + ): + + super().__init__() + self.dim = dim + self.window_size = window_size # Wh, Ww + self.num_heads = num_heads + head_dim = dim // num_heads + self.scale = qk_scale or head_dim**-0.5 + + # define a parameter table of relative position bias + self.relative_position_bias_table = nn.Parameter( + torch.zeros((2 * window_size[0] - 1) * (2 * window_size[1] - 1), num_heads) + ) # 2*Wh-1 * 2*Ww-1, nH + + # get pair-wise relative position index for each token inside the window + coords_h = torch.arange(self.window_size[0]) + coords_w = torch.arange(self.window_size[1]) + coords = torch.stack(torch.meshgrid([coords_h, coords_w])) # 2, Wh, Ww + coords_flatten = torch.flatten(coords, 1) # 2, Wh*Ww + relative_coords = coords_flatten[:, :, None] - coords_flatten[:, None, :] # 2, Wh*Ww, Wh*Ww + relative_coords = relative_coords.permute(1, 2, 0).contiguous() # Wh*Ww, Wh*Ww, 2 + relative_coords[:, :, 0] += self.window_size[0] - 1 # shift to start from 0 + relative_coords[:, :, 1] += self.window_size[1] - 1 + relative_coords[:, :, 0] *= 2 * self.window_size[1] - 1 + relative_position_index = relative_coords.sum(-1) # Wh*Ww, Wh*Ww + self.register_buffer("relative_position_index", relative_position_index) + + self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias) + self.attn_drop = nn.Dropout(attn_drop) + self.proj = nn.Linear(dim, dim) + self.proj_drop = nn.Dropout(proj_drop) + + trunc_normal_(self.relative_position_bias_table, std=0.02) + self.softmax = nn.Softmax(dim=-1) + + def forward(self, x, mask=None): + """Forward function. + Args: + x: input features with shape of (num_windows*B, N, C) + mask: (0/-inf) mask with shape of (num_windows, Wh*Ww, Wh*Ww) or None + """ + B_, N, C = x.shape + qkv = ( + self.qkv(x) + .reshape(B_, N, 3, self.num_heads, C // self.num_heads) + .permute(2, 0, 3, 1, 4) + ) + q, k, v = qkv[0], qkv[1], qkv[2] # make torchscript happy (cannot use tensor as tuple) + + q = q * self.scale + attn = q @ k.transpose(-2, -1) + + relative_position_bias = self.relative_position_bias_table[ + self.relative_position_index.view(-1) + ].view( + self.window_size[0] * self.window_size[1], self.window_size[0] * self.window_size[1], -1 + ) # Wh*Ww,Wh*Ww,nH + relative_position_bias = relative_position_bias.permute( + 2, 0, 1 + ).contiguous() # nH, Wh*Ww, Wh*Ww + attn = attn + relative_position_bias.unsqueeze(0) + + if mask is not None: + nW = mask.shape[0] + attn = attn.view(B_ // nW, nW, self.num_heads, N, N) + mask.unsqueeze(1).unsqueeze(0) + attn = attn.view(-1, self.num_heads, N, N) + attn = self.softmax(attn) + else: + attn = self.softmax(attn) + + attn = self.attn_drop(attn) + + x = (attn @ v).transpose(1, 2).reshape(B_, N, C) + x = self.proj(x) + x = self.proj_drop(x) + return x + + +class SwinTransformerBlock(nn.Module): + """Swin Transformer Block. + Args: + dim (int): Number of input channels. + num_heads (int): Number of attention heads. + window_size (int): Window size. + shift_size (int): Shift size for SW-MSA. + mlp_ratio (float): Ratio of mlp hidden dim to embedding dim. + qkv_bias (bool, optional): If True, add a learnable bias to query, key, value. Default: True + qk_scale (float | None, optional): Override default qk scale of head_dim ** -0.5 if set. + drop (float, optional): Dropout rate. Default: 0.0 + attn_drop (float, optional): Attention dropout rate. Default: 0.0 + drop_path (float, optional): Stochastic depth rate. Default: 0.0 + act_layer (nn.Module, optional): Activation layer. Default: nn.GELU + norm_layer (nn.Module, optional): Normalization layer. Default: nn.LayerNorm + """ + + def __init__( + self, + dim, + num_heads, + window_size=7, + shift_size=0, + mlp_ratio=4.0, + qkv_bias=True, + qk_scale=None, + drop=0.0, + attn_drop=0.0, + drop_path=0.0, + act_layer=nn.GELU, + norm_layer=nn.LayerNorm, + ): + super().__init__() + self.dim = dim + self.num_heads = num_heads + self.window_size = window_size + self.shift_size = shift_size + self.mlp_ratio = mlp_ratio + assert 0 <= self.shift_size < self.window_size, "shift_size must in 0-window_size" + + self.norm1 = norm_layer(dim) + self.attn = WindowAttention( + dim, + window_size=to_2tuple(self.window_size), + num_heads=num_heads, + qkv_bias=qkv_bias, + qk_scale=qk_scale, + attn_drop=attn_drop, + proj_drop=drop, + ) + + self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity() + self.norm2 = norm_layer(dim) + mlp_hidden_dim = int(dim * mlp_ratio) + self.mlp = Mlp( + in_features=dim, hidden_features=mlp_hidden_dim, act_layer=act_layer, drop=drop + ) + + self.H = None + self.W = None + + def forward(self, x, mask_matrix): + """Forward function. + Args: + x: Input feature, tensor size (B, H*W, C). + H, W: Spatial resolution of the input feature. + mask_matrix: Attention mask for cyclic shift. + """ + B, L, C = x.shape + H, W = self.H, self.W + assert L == H * W, "input feature has wrong size" + + shortcut = x + x = self.norm1(x) + x = x.view(B, H, W, C) + + # pad feature maps to multiples of window size + pad_l = pad_t = 0 + pad_r = (self.window_size - W % self.window_size) % self.window_size + pad_b = (self.window_size - H % self.window_size) % self.window_size + x = F.pad(x, (0, 0, pad_l, pad_r, pad_t, pad_b)) + _, Hp, Wp, _ = x.shape + + # cyclic shift + if self.shift_size > 0: + shifted_x = torch.roll(x, shifts=(-self.shift_size, -self.shift_size), dims=(1, 2)) + attn_mask = mask_matrix + else: + shifted_x = x + attn_mask = None + + # partition windows + x_windows = window_partition( + shifted_x, self.window_size + ) # nW*B, window_size, window_size, C + x_windows = x_windows.view( + -1, self.window_size * self.window_size, C + ) # nW*B, window_size*window_size, C + + # W-MSA/SW-MSA + attn_windows = self.attn(x_windows, mask=attn_mask) # nW*B, window_size*window_size, C + + # merge windows + attn_windows = attn_windows.view(-1, self.window_size, self.window_size, C) + shifted_x = window_reverse(attn_windows, self.window_size, Hp, Wp) # B H' W' C + + # reverse cyclic shift + if self.shift_size > 0: + x = torch.roll(shifted_x, shifts=(self.shift_size, self.shift_size), dims=(1, 2)) + else: + x = shifted_x + + if pad_r > 0 or pad_b > 0: + x = x[:, :H, :W, :].contiguous() + + x = x.view(B, H * W, C) + + # FFN + x = shortcut + self.drop_path(x) + x = x + self.drop_path(self.mlp(self.norm2(x))) + + return x + + +class PatchMerging(nn.Module): + """Patch Merging Layer + Args: + dim (int): Number of input channels. + norm_layer (nn.Module, optional): Normalization layer. Default: nn.LayerNorm + """ + + def __init__(self, dim, norm_layer=nn.LayerNorm): + super().__init__() + self.dim = dim + self.reduction = nn.Linear(4 * dim, 2 * dim, bias=False) + self.norm = norm_layer(4 * dim) + + def forward(self, x, H, W): + """Forward function. + Args: + x: Input feature, tensor size (B, H*W, C). + H, W: Spatial resolution of the input feature. + """ + B, L, C = x.shape + assert L == H * W, "input feature has wrong size" + + x = x.view(B, H, W, C) + + # padding + pad_input = (H % 2 == 1) or (W % 2 == 1) + if pad_input: + x = F.pad(x, (0, 0, 0, W % 2, 0, H % 2)) + + x0 = x[:, 0::2, 0::2, :] # B H/2 W/2 C + x1 = x[:, 1::2, 0::2, :] # B H/2 W/2 C + x2 = x[:, 0::2, 1::2, :] # B H/2 W/2 C + x3 = x[:, 1::2, 1::2, :] # B H/2 W/2 C + x = torch.cat([x0, x1, x2, x3], -1) # B H/2 W/2 4*C + x = x.view(B, -1, 4 * C) # B H/2*W/2 4*C + + x = self.norm(x) + x = self.reduction(x) + + return x + + +class BasicLayer(nn.Module): + """A basic Swin Transformer layer for one stage. + Args: + dim (int): Number of feature channels + depth (int): Depths of this stage. + num_heads (int): Number of attention head. + window_size (int): Local window size. Default: 7. + mlp_ratio (float): Ratio of mlp hidden dim to embedding dim. Default: 4. + qkv_bias (bool, optional): If True, add a learnable bias to query, key, value. Default: True + qk_scale (float | None, optional): Override default qk scale of head_dim ** -0.5 if set. + drop (float, optional): Dropout rate. Default: 0.0 + attn_drop (float, optional): Attention dropout rate. Default: 0.0 + drop_path (float | tuple[float], optional): Stochastic depth rate. Default: 0.0 + norm_layer (nn.Module, optional): Normalization layer. Default: nn.LayerNorm + downsample (nn.Module | None, optional): Downsample layer at the end of the layer. Default: None + use_checkpoint (bool): Whether to use checkpointing to save memory. Default: False. + """ + + def __init__( + self, + dim, + depth, + num_heads, + window_size=7, + mlp_ratio=4.0, + qkv_bias=True, + qk_scale=None, + drop=0.0, + attn_drop=0.0, + drop_path=0.0, + norm_layer=nn.LayerNorm, + downsample=None, + use_checkpoint=False, + ): + super().__init__() + self.window_size = window_size + self.shift_size = window_size // 2 + self.depth = depth + self.use_checkpoint = use_checkpoint + + # build blocks + self.blocks = nn.ModuleList( + [ + SwinTransformerBlock( + dim=dim, + num_heads=num_heads, + window_size=window_size, + shift_size=0 if (i % 2 == 0) else window_size // 2, + mlp_ratio=mlp_ratio, + qkv_bias=qkv_bias, + qk_scale=qk_scale, + drop=drop, + attn_drop=attn_drop, + drop_path=drop_path[i] if isinstance(drop_path, list) else drop_path, + norm_layer=norm_layer, + ) + for i in range(depth) + ] + ) + + # patch merging layer + if downsample is not None: + self.downsample = downsample(dim=dim, norm_layer=norm_layer) + else: + self.downsample = None + + def forward(self, x, H, W): + """Forward function. + Args: + x: Input feature, tensor size (B, H*W, C). + H, W: Spatial resolution of the input feature. + """ + + # calculate attention mask for SW-MSA + Hp = int(np.ceil(H / self.window_size)) * self.window_size + Wp = int(np.ceil(W / self.window_size)) * self.window_size + img_mask = torch.zeros((1, Hp, Wp, 1), device=x.device) # 1 Hp Wp 1 + h_slices = ( + slice(0, -self.window_size), + slice(-self.window_size, -self.shift_size), + slice(-self.shift_size, None), + ) + w_slices = ( + slice(0, -self.window_size), + slice(-self.window_size, -self.shift_size), + slice(-self.shift_size, None), + ) + cnt = 0 + for h in h_slices: + for w in w_slices: + img_mask[:, h, w, :] = cnt + cnt += 1 + + mask_windows = window_partition( + img_mask, self.window_size + ) # nW, window_size, window_size, 1 + mask_windows = mask_windows.view(-1, self.window_size * self.window_size) + attn_mask = mask_windows.unsqueeze(1) - mask_windows.unsqueeze(2) + attn_mask = attn_mask.masked_fill(attn_mask != 0, float(-100.0)).masked_fill( + attn_mask == 0, float(0.0) + ) + + for blk in self.blocks: + blk.H, blk.W = H, W + if self.use_checkpoint: + x = checkpoint.checkpoint(blk, x, attn_mask) + else: + x = blk(x, attn_mask) + if self.downsample is not None: + x_down = self.downsample(x, H, W) + Wh, Ww = (H + 1) // 2, (W + 1) // 2 + return x, H, W, x_down, Wh, Ww + else: + return x, H, W, x, H, W + + +class PatchEmbed(nn.Module): + """Image to Patch Embedding + Args: + patch_size (int): Patch token size. Default: 4. + in_chans (int): Number of input image channels. Default: 3. + embed_dim (int): Number of linear projection output channels. Default: 96. + norm_layer (nn.Module, optional): Normalization layer. Default: None + """ + + def __init__(self, patch_size=4, in_chans=3, embed_dim=96, norm_layer=None): + super().__init__() + patch_size = to_2tuple(patch_size) + self.patch_size = patch_size + + self.in_chans = in_chans + self.embed_dim = embed_dim + + self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size) + if norm_layer is not None: + self.norm = norm_layer(embed_dim) + else: + self.norm = None + + def forward(self, x): + """Forward function.""" + # padding + _, _, H, W = x.size() + if W % self.patch_size[1] != 0: + x = F.pad(x, (0, self.patch_size[1] - W % self.patch_size[1])) + if H % self.patch_size[0] != 0: + x = F.pad(x, (0, 0, 0, self.patch_size[0] - H % self.patch_size[0])) + + x = self.proj(x) # B C Wh Ww + if self.norm is not None: + Wh, Ww = x.size(2), x.size(3) + x = x.flatten(2).transpose(1, 2) + x = self.norm(x) + x = x.transpose(1, 2).view(-1, self.embed_dim, Wh, Ww) + + return x + + +class SwinTransformer(nn.Module): + """Swin Transformer backbone. + A PyTorch impl of : `Swin Transformer: Hierarchical Vision Transformer using Shifted Windows` - + https://arxiv.org/pdf/2103.14030 + Args: + pretrain_img_size (int): Input image size for training the pretrained model, + used in absolute postion embedding. Default 224. + patch_size (int | tuple(int)): Patch size. Default: 4. + in_chans (int): Number of input image channels. Default: 3. + embed_dim (int): Number of linear projection output channels. Default: 96. + depths (tuple[int]): Depths of each Swin Transformer stage. + num_heads (tuple[int]): Number of attention head of each stage. + window_size (int): Window size. Default: 7. + mlp_ratio (float): Ratio of mlp hidden dim to embedding dim. Default: 4. + qkv_bias (bool): If True, add a learnable bias to query, key, value. Default: True + qk_scale (float): Override default qk scale of head_dim ** -0.5 if set. + drop_rate (float): Dropout rate. + attn_drop_rate (float): Attention dropout rate. Default: 0. + drop_path_rate (float): Stochastic depth rate. Default: 0.2. + norm_layer (nn.Module): Normalization layer. Default: nn.LayerNorm. + ape (bool): If True, add absolute position embedding to the patch embedding. Default: False. + patch_norm (bool): If True, add normalization after patch embedding. Default: True. + out_indices (Sequence[int]): Output from which stages. + frozen_stages (int): Stages to be frozen (stop grad and set eval mode). + -1 means not freezing any parameters. + use_checkpoint (bool): Whether to use checkpointing to save memory. Default: False. + dilation (bool): if True, the output size if 16x downsample, ow 32x downsample. + """ + + def __init__( + self, + pretrain_img_size=224, + patch_size=4, + in_chans=3, + embed_dim=96, + depths=[2, 2, 6, 2], + num_heads=[3, 6, 12, 24], + window_size=7, + mlp_ratio=4.0, + qkv_bias=True, + qk_scale=None, + drop_rate=0.0, + attn_drop_rate=0.0, + drop_path_rate=0.2, + norm_layer=nn.LayerNorm, + ape=False, + patch_norm=True, + out_indices=(0, 1, 2, 3), + frozen_stages=-1, + dilation=False, + use_checkpoint=False, + ): + super().__init__() + + self.pretrain_img_size = pretrain_img_size + self.num_layers = len(depths) + self.embed_dim = embed_dim + self.ape = ape + self.patch_norm = patch_norm + self.out_indices = out_indices + self.frozen_stages = frozen_stages + self.dilation = dilation + + # if use_checkpoint: + # print("use_checkpoint!!!!!!!!!!!!!!!!!!!!!!!!") + + # split image into non-overlapping patches + self.patch_embed = PatchEmbed( + patch_size=patch_size, + in_chans=in_chans, + embed_dim=embed_dim, + norm_layer=norm_layer if self.patch_norm else None, + ) + + # absolute position embedding + if self.ape: + pretrain_img_size = to_2tuple(pretrain_img_size) + patch_size = to_2tuple(patch_size) + patches_resolution = [ + pretrain_img_size[0] // patch_size[0], + pretrain_img_size[1] // patch_size[1], + ] + + self.absolute_pos_embed = nn.Parameter( + torch.zeros(1, embed_dim, patches_resolution[0], patches_resolution[1]) + ) + trunc_normal_(self.absolute_pos_embed, std=0.02) + + self.pos_drop = nn.Dropout(p=drop_rate) + + # stochastic depth + dpr = [ + x.item() for x in torch.linspace(0, drop_path_rate, sum(depths)) + ] # stochastic depth decay rule + + # build layers + self.layers = nn.ModuleList() + # prepare downsample list + downsamplelist = [PatchMerging for i in range(self.num_layers)] + downsamplelist[-1] = None + num_features = [int(embed_dim * 2**i) for i in range(self.num_layers)] + if self.dilation: + downsamplelist[-2] = None + num_features[-1] = int(embed_dim * 2 ** (self.num_layers - 1)) // 2 + for i_layer in range(self.num_layers): + layer = BasicLayer( + # dim=int(embed_dim * 2 ** i_layer), + dim=num_features[i_layer], + depth=depths[i_layer], + num_heads=num_heads[i_layer], + window_size=window_size, + mlp_ratio=mlp_ratio, + qkv_bias=qkv_bias, + qk_scale=qk_scale, + drop=drop_rate, + attn_drop=attn_drop_rate, + drop_path=dpr[sum(depths[:i_layer]) : sum(depths[: i_layer + 1])], + norm_layer=norm_layer, + # downsample=PatchMerging if (i_layer < self.num_layers - 1) else None, + downsample=downsamplelist[i_layer], + use_checkpoint=use_checkpoint, + ) + self.layers.append(layer) + + # num_features = [int(embed_dim * 2 ** i) for i in range(self.num_layers)] + self.num_features = num_features + + # add a norm layer for each output + for i_layer in out_indices: + layer = norm_layer(num_features[i_layer]) + layer_name = f"norm{i_layer}" + self.add_module(layer_name, layer) + + self._freeze_stages() + + def _freeze_stages(self): + if self.frozen_stages >= 0: + self.patch_embed.eval() + for param in self.patch_embed.parameters(): + param.requires_grad = False + + if self.frozen_stages >= 1 and self.ape: + self.absolute_pos_embed.requires_grad = False + + if self.frozen_stages >= 2: + self.pos_drop.eval() + for i in range(0, self.frozen_stages - 1): + m = self.layers[i] + m.eval() + for param in m.parameters(): + param.requires_grad = False + + # def init_weights(self, pretrained=None): + # """Initialize the weights in backbone. + # Args: + # pretrained (str, optional): Path to pre-trained weights. + # Defaults to None. + # """ + + # def _init_weights(m): + # if isinstance(m, nn.Linear): + # trunc_normal_(m.weight, std=.02) + # if isinstance(m, nn.Linear) and m.bias is not None: + # nn.init.constant_(m.bias, 0) + # elif isinstance(m, nn.LayerNorm): + # nn.init.constant_(m.bias, 0) + # nn.init.constant_(m.weight, 1.0) + + # if isinstance(pretrained, str): + # self.apply(_init_weights) + # logger = get_root_logger() + # load_checkpoint(self, pretrained, strict=False, logger=logger) + # elif pretrained is None: + # self.apply(_init_weights) + # else: + # raise TypeError('pretrained must be a str or None') + + def forward_raw(self, x): + """Forward function.""" + x = self.patch_embed(x) + + Wh, Ww = x.size(2), x.size(3) + if self.ape: + # interpolate the position embedding to the corresponding size + absolute_pos_embed = F.interpolate( + self.absolute_pos_embed, size=(Wh, Ww), mode="bicubic" + ) + x = (x + absolute_pos_embed).flatten(2).transpose(1, 2) # B Wh*Ww C + else: + x = x.flatten(2).transpose(1, 2) + x = self.pos_drop(x) + + outs = [] + for i in range(self.num_layers): + layer = self.layers[i] + x_out, H, W, x, Wh, Ww = layer(x, Wh, Ww) + # import ipdb; ipdb.set_trace() + + if i in self.out_indices: + norm_layer = getattr(self, f"norm{i}") + x_out = norm_layer(x_out) + + out = x_out.view(-1, H, W, self.num_features[i]).permute(0, 3, 1, 2).contiguous() + outs.append(out) + # in: + # torch.Size([2, 3, 1024, 1024]) + # outs: + # [torch.Size([2, 192, 256, 256]), torch.Size([2, 384, 128, 128]), \ + # torch.Size([2, 768, 64, 64]), torch.Size([2, 1536, 32, 32])] + return tuple(outs) + + def forward(self, tensor_list: NestedTensor): + x = tensor_list.tensors + + """Forward function.""" + x = self.patch_embed(x) + + Wh, Ww = x.size(2), x.size(3) + if self.ape: + # interpolate the position embedding to the corresponding size + absolute_pos_embed = F.interpolate( + self.absolute_pos_embed, size=(Wh, Ww), mode="bicubic" + ) + x = (x + absolute_pos_embed).flatten(2).transpose(1, 2) # B Wh*Ww C + else: + x = x.flatten(2).transpose(1, 2) + x = self.pos_drop(x) + + outs = [] + for i in range(self.num_layers): + layer = self.layers[i] + x_out, H, W, x, Wh, Ww = layer(x, Wh, Ww) + + if i in self.out_indices: + norm_layer = getattr(self, f"norm{i}") + x_out = norm_layer(x_out) + + out = x_out.view(-1, H, W, self.num_features[i]).permute(0, 3, 1, 2).contiguous() + outs.append(out) + # in: + # torch.Size([2, 3, 1024, 1024]) + # out: + # [torch.Size([2, 192, 256, 256]), torch.Size([2, 384, 128, 128]), \ + # torch.Size([2, 768, 64, 64]), torch.Size([2, 1536, 32, 32])] + + # collect for nesttensors + outs_dict = {} + for idx, out_i in enumerate(outs): + m = tensor_list.mask + assert m is not None + mask = F.interpolate(m[None].float(), size=out_i.shape[-2:]).to(torch.bool)[0] + outs_dict[idx] = NestedTensor(out_i, mask) + + return outs_dict + + def train(self, mode=True): + """Convert the model into training mode while keep layers freezed.""" + super(SwinTransformer, self).train(mode) + self._freeze_stages() + + +def build_swin_transformer(modelname, pretrain_img_size, **kw): + assert modelname in [ + "swin_T_224_1k", + "swin_B_224_22k", + "swin_B_384_22k", + "swin_L_224_22k", + "swin_L_384_22k", + ] + + model_para_dict = { + "swin_T_224_1k": dict( + embed_dim=96, depths=[2, 2, 6, 2], num_heads=[3, 6, 12, 24], window_size=7 + ), + "swin_B_224_22k": dict( + embed_dim=128, depths=[2, 2, 18, 2], num_heads=[4, 8, 16, 32], window_size=7 + ), + "swin_B_384_22k": dict( + embed_dim=128, depths=[2, 2, 18, 2], num_heads=[4, 8, 16, 32], window_size=12 + ), + "swin_L_224_22k": dict( + embed_dim=192, depths=[2, 2, 18, 2], num_heads=[6, 12, 24, 48], window_size=7 + ), + "swin_L_384_22k": dict( + embed_dim=192, depths=[2, 2, 18, 2], num_heads=[6, 12, 24, 48], window_size=12 + ), + } + kw_cgf = model_para_dict[modelname] + kw_cgf.update(kw) + model = SwinTransformer(pretrain_img_size=pretrain_img_size, **kw_cgf) + return model + + +if __name__ == "__main__": + model = build_swin_transformer("swin_L_384_22k", 384, dilation=True) + x = torch.rand(2, 3, 1024, 1024) + y = model.forward_raw(x) + import ipdb + + ipdb.set_trace() + x = torch.rand(2, 3, 384, 384) + y = model.forward_raw(x) + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/bertwarper.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/bertwarper.py new file mode 100644 index 0000000..d340836 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/bertwarper.py @@ -0,0 +1,270 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +import torch +from torch import nn +from transformers.modeling_outputs import BaseModelOutputWithPoolingAndCrossAttentions + + +class BertModelWarper(nn.Module): + def __init__(self, bert_model): + super().__init__() + # self.bert = bert_modelc + + self.config = bert_model.config + self.embeddings = bert_model.embeddings + self.encoder = bert_model.encoder + self.pooler = bert_model.pooler + + self.get_extended_attention_mask = bert_model.get_extended_attention_mask + self.invert_attention_mask = bert_model.invert_attention_mask + self.get_head_mask = bert_model.get_head_mask + + def forward( + self, + input_ids=None, + attention_mask=None, + token_type_ids=None, + position_ids=None, + head_mask=None, + inputs_embeds=None, + encoder_hidden_states=None, + encoder_attention_mask=None, + past_key_values=None, + use_cache=None, + output_attentions=None, + output_hidden_states=None, + return_dict=None, + ): + r""" + encoder_hidden_states (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length, hidden_size)`, `optional`): + Sequence of hidden-states at the output of the last layer of the encoder. Used in the cross-attention if + the model is configured as a decoder. + encoder_attention_mask (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length)`, `optional`): + Mask to avoid performing attention on the padding token indices of the encoder input. This mask is used in + the cross-attention if the model is configured as a decoder. Mask values selected in ``[0, 1]``: + + - 1 for tokens that are **not masked**, + - 0 for tokens that are **masked**. + past_key_values (:obj:`tuple(tuple(torch.FloatTensor))` of length :obj:`config.n_layers` with each tuple having 4 tensors of shape :obj:`(batch_size, num_heads, sequence_length - 1, embed_size_per_head)`): + Contains precomputed key and value hidden states of the attention blocks. Can be used to speed up decoding. + + If :obj:`past_key_values` are used, the user can optionally input only the last :obj:`decoder_input_ids` + (those that don't have their past key value states given to this model) of shape :obj:`(batch_size, 1)` + instead of all :obj:`decoder_input_ids` of shape :obj:`(batch_size, sequence_length)`. + use_cache (:obj:`bool`, `optional`): + If set to :obj:`True`, :obj:`past_key_values` key value states are returned and can be used to speed up + decoding (see :obj:`past_key_values`). + """ + output_attentions = ( + output_attentions if output_attentions is not None else self.config.output_attentions + ) + output_hidden_states = ( + output_hidden_states + if output_hidden_states is not None + else self.config.output_hidden_states + ) + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + if self.config.is_decoder: + use_cache = use_cache if use_cache is not None else self.config.use_cache + else: + use_cache = False + + if input_ids is not None and inputs_embeds is not None: + raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time") + elif input_ids is not None: + input_shape = input_ids.size() + batch_size, seq_length = input_shape + elif inputs_embeds is not None: + input_shape = inputs_embeds.size()[:-1] + batch_size, seq_length = input_shape + else: + raise ValueError("You have to specify either input_ids or inputs_embeds") + + device = input_ids.device if input_ids is not None else inputs_embeds.device + + # past_key_values_length + past_key_values_length = ( + past_key_values[0][0].shape[2] if past_key_values is not None else 0 + ) + + if attention_mask is None: + attention_mask = torch.ones( + ((batch_size, seq_length + past_key_values_length)), device=device + ) + if token_type_ids is None: + token_type_ids = torch.zeros(input_shape, dtype=torch.long, device=device) + + # We can provide a self-attention mask of dimensions [batch_size, from_seq_length, to_seq_length] + # ourselves in which case we just need to make it broadcastable to all heads. + extended_attention_mask: torch.Tensor = self.get_extended_attention_mask( + attention_mask, input_shape, device + ) + + # If a 2D or 3D attention mask is provided for the cross-attention + # we need to make broadcastable to [batch_size, num_heads, seq_length, seq_length] + if self.config.is_decoder and encoder_hidden_states is not None: + encoder_batch_size, encoder_sequence_length, _ = encoder_hidden_states.size() + encoder_hidden_shape = (encoder_batch_size, encoder_sequence_length) + if encoder_attention_mask is None: + encoder_attention_mask = torch.ones(encoder_hidden_shape, device=device) + encoder_extended_attention_mask = self.invert_attention_mask(encoder_attention_mask) + else: + encoder_extended_attention_mask = None + # if os.environ.get('IPDB_SHILONG_DEBUG', None) == 'INFO': + # import ipdb; ipdb.set_trace() + + # Prepare head mask if needed + # 1.0 in head_mask indicate we keep the head + # attention_probs has shape bsz x n_heads x N x N + # input head_mask has shape [num_heads] or [num_hidden_layers x num_heads] + # and head_mask is converted to shape [num_hidden_layers x batch x num_heads x seq_length x seq_length] + head_mask = self.get_head_mask(head_mask, self.config.num_hidden_layers) + + embedding_output = self.embeddings( + input_ids=input_ids, + position_ids=position_ids, + token_type_ids=token_type_ids, + inputs_embeds=inputs_embeds, + past_key_values_length=past_key_values_length, + ) + + encoder_outputs = self.encoder( + embedding_output, + attention_mask=extended_attention_mask, + head_mask=head_mask, + encoder_hidden_states=encoder_hidden_states, + encoder_attention_mask=encoder_extended_attention_mask, + past_key_values=past_key_values, + use_cache=use_cache, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + ) + sequence_output = encoder_outputs[0] + pooled_output = self.pooler(sequence_output) if self.pooler is not None else None + + if not return_dict: + return (sequence_output, pooled_output) + encoder_outputs[1:] + + return BaseModelOutputWithPoolingAndCrossAttentions( + last_hidden_state=sequence_output, + pooler_output=pooled_output, + past_key_values=encoder_outputs.past_key_values, + hidden_states=encoder_outputs.hidden_states, + attentions=encoder_outputs.attentions, + cross_attentions=encoder_outputs.cross_attentions, + ) + + +class TextEncoderShell(nn.Module): + def __init__(self, text_encoder): + super().__init__() + self.text_encoder = text_encoder + self.config = self.text_encoder.config + + def forward(self, **kw): + # feed into text encoder + return self.text_encoder(**kw) + + +def generate_masks_with_special_tokens(tokenized, special_tokens_list, tokenizer): + """Generate attention mask between each pair of special tokens + Args: + input_ids (torch.Tensor): input ids. Shape: [bs, num_token] + special_tokens_mask (list): special tokens mask. + Returns: + torch.Tensor: attention mask between each special tokens. + """ + input_ids = tokenized["input_ids"] + bs, num_token = input_ids.shape + # special_tokens_mask: bs, num_token. 1 for special tokens. 0 for normal tokens + special_tokens_mask = torch.zeros((bs, num_token), device=input_ids.device).bool() + for special_token in special_tokens_list: + special_tokens_mask |= input_ids == special_token + + # idxs: each row is a list of indices of special tokens + idxs = torch.nonzero(special_tokens_mask) + + # generate attention mask and positional ids + attention_mask = ( + torch.eye(num_token, device=input_ids.device).bool().unsqueeze(0).repeat(bs, 1, 1) + ) + position_ids = torch.zeros((bs, num_token), device=input_ids.device) + previous_col = 0 + for i in range(idxs.shape[0]): + row, col = idxs[i] + if (col == 0) or (col == num_token - 1): + attention_mask[row, col, col] = True + position_ids[row, col] = 0 + else: + attention_mask[row, previous_col + 1 : col + 1, previous_col + 1 : col + 1] = True + position_ids[row, previous_col + 1 : col + 1] = torch.arange( + 0, col - previous_col, device=input_ids.device + ) + + previous_col = col + + # # padding mask + # padding_mask = tokenized['attention_mask'] + # attention_mask = attention_mask & padding_mask.unsqueeze(1).bool() & padding_mask.unsqueeze(2).bool() + + return attention_mask, position_ids.to(torch.long) + + +def generate_masks_with_special_tokens_and_transfer_map(tokenized, special_tokens_list, tokenizer): + """Generate attention mask between each pair of special tokens + Args: + input_ids (torch.Tensor): input ids. Shape: [bs, num_token] + special_tokens_mask (list): special tokens mask. + Returns: + torch.Tensor: attention mask between each special tokens. + """ + input_ids = tokenized["input_ids"] + bs, num_token = input_ids.shape + # special_tokens_mask: bs, num_token. 1 for special tokens. 0 for normal tokens + special_tokens_mask = torch.zeros((bs, num_token), device=input_ids.device).bool() + for special_token in special_tokens_list: + special_tokens_mask |= input_ids == special_token + + # idxs: each row is a list of indices of special tokens + idxs = torch.nonzero(special_tokens_mask) + + # generate attention mask and positional ids + attention_mask = ( + torch.eye(num_token, device=input_ids.device).bool().unsqueeze(0).repeat(bs, 1, 1) + ) + position_ids = torch.zeros((bs, num_token), device=input_ids.device) + cate_to_token_mask_list = [[] for _ in range(bs)] + previous_col = 0 + for i in range(idxs.shape[0]): + row, col = idxs[i] + if (col == 0) or (col == num_token - 1): + attention_mask[row, col, col] = True + position_ids[row, col] = 0 + else: + attention_mask[row, previous_col + 1 : col + 1, previous_col + 1 : col + 1] = True + position_ids[row, previous_col + 1 : col + 1] = torch.arange( + 0, col - previous_col, device=input_ids.device + ) + c2t_maski = torch.zeros((num_token), device=input_ids.device).bool() + c2t_maski[previous_col + 1 : col] = True + cate_to_token_mask_list[row].append(c2t_maski) + previous_col = col + + cate_to_token_mask_list = [ + torch.stack(cate_to_token_mask_listi, dim=0) + for cate_to_token_mask_listi in cate_to_token_mask_list + ] + + # # padding mask + # padding_mask = tokenized['attention_mask'] + # attention_mask = attention_mask & padding_mask.unsqueeze(1).bool() & padding_mask.unsqueeze(2).bool() + + return attention_mask, position_ids.to(torch.long), cate_to_token_mask_list + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/fuse_modules.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/fuse_modules.py new file mode 100644 index 0000000..2f2b884 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/fuse_modules.py @@ -0,0 +1,298 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +import torch +import torch.nn as nn +import torch.nn.functional as F +from timm.models.layers import DropPath + + +class FeatureResizer(nn.Module): + """ + This class takes as input a set of embeddings of dimension C1 and outputs a set of + embedding of dimension C2, after a linear transformation, dropout and normalization (LN). + """ + + def __init__(self, input_feat_size, output_feat_size, dropout, do_ln=True): + super().__init__() + self.do_ln = do_ln + # Object feature encoding + self.fc = nn.Linear(input_feat_size, output_feat_size, bias=True) + self.layer_norm = nn.LayerNorm(output_feat_size, eps=1e-12) + self.dropout = nn.Dropout(dropout) + + def forward(self, encoder_features): + x = self.fc(encoder_features) + if self.do_ln: + x = self.layer_norm(x) + output = self.dropout(x) + return output + + +def l1norm(X, dim, eps=1e-8): + """L1-normalize columns of X""" + norm = torch.abs(X).sum(dim=dim, keepdim=True) + eps + X = torch.div(X, norm) + return X + + +def l2norm(X, dim, eps=1e-8): + """L2-normalize columns of X""" + norm = torch.pow(X, 2).sum(dim=dim, keepdim=True).sqrt() + eps + X = torch.div(X, norm) + return X + + +def func_attention(query, context, smooth=1, raw_feature_norm="softmax", eps=1e-8): + """ + query: (n_context, queryL, d) + context: (n_context, sourceL, d) + """ + batch_size_q, queryL = query.size(0), query.size(1) + batch_size, sourceL = context.size(0), context.size(1) + + # Get attention + # --> (batch, d, queryL) + queryT = torch.transpose(query, 1, 2) + + # (batch, sourceL, d)(batch, d, queryL) + # --> (batch, sourceL, queryL) + attn = torch.bmm(context, queryT) + if raw_feature_norm == "softmax": + # --> (batch*sourceL, queryL) + attn = attn.view(batch_size * sourceL, queryL) + attn = nn.Softmax()(attn) + # --> (batch, sourceL, queryL) + attn = attn.view(batch_size, sourceL, queryL) + elif raw_feature_norm == "l2norm": + attn = l2norm(attn, 2) + elif raw_feature_norm == "clipped_l2norm": + attn = nn.LeakyReLU(0.1)(attn) + attn = l2norm(attn, 2) + else: + raise ValueError("unknown first norm type:", raw_feature_norm) + # --> (batch, queryL, sourceL) + attn = torch.transpose(attn, 1, 2).contiguous() + # --> (batch*queryL, sourceL) + attn = attn.view(batch_size * queryL, sourceL) + attn = nn.Softmax()(attn * smooth) + # --> (batch, queryL, sourceL) + attn = attn.view(batch_size, queryL, sourceL) + # --> (batch, sourceL, queryL) + attnT = torch.transpose(attn, 1, 2).contiguous() + + # --> (batch, d, sourceL) + contextT = torch.transpose(context, 1, 2) + # (batch x d x sourceL)(batch x sourceL x queryL) + # --> (batch, d, queryL) + weightedContext = torch.bmm(contextT, attnT) + # --> (batch, queryL, d) + weightedContext = torch.transpose(weightedContext, 1, 2) + + return weightedContext, attnT + + +class BiMultiHeadAttention(nn.Module): + def __init__(self, v_dim, l_dim, embed_dim, num_heads, dropout=0.1, cfg=None): + super(BiMultiHeadAttention, self).__init__() + + self.embed_dim = embed_dim + self.num_heads = num_heads + self.head_dim = embed_dim // num_heads + self.v_dim = v_dim + self.l_dim = l_dim + + assert ( + self.head_dim * self.num_heads == self.embed_dim + ), f"embed_dim must be divisible by num_heads (got `embed_dim`: {self.embed_dim} and `num_heads`: {self.num_heads})." + self.scale = self.head_dim ** (-0.5) + self.dropout = dropout + + self.v_proj = nn.Linear(self.v_dim, self.embed_dim) + self.l_proj = nn.Linear(self.l_dim, self.embed_dim) + self.values_v_proj = nn.Linear(self.v_dim, self.embed_dim) + self.values_l_proj = nn.Linear(self.l_dim, self.embed_dim) + + self.out_v_proj = nn.Linear(self.embed_dim, self.v_dim) + self.out_l_proj = nn.Linear(self.embed_dim, self.l_dim) + + self.stable_softmax_2d = True + self.clamp_min_for_underflow = True + self.clamp_max_for_overflow = True + + self._reset_parameters() + + def _shape(self, tensor: torch.Tensor, seq_len: int, bsz: int): + return tensor.view(bsz, seq_len, self.num_heads, self.head_dim).transpose(1, 2).contiguous() + + def _reset_parameters(self): + nn.init.xavier_uniform_(self.v_proj.weight) + self.v_proj.bias.data.fill_(0) + nn.init.xavier_uniform_(self.l_proj.weight) + self.l_proj.bias.data.fill_(0) + nn.init.xavier_uniform_(self.values_v_proj.weight) + self.values_v_proj.bias.data.fill_(0) + nn.init.xavier_uniform_(self.values_l_proj.weight) + self.values_l_proj.bias.data.fill_(0) + nn.init.xavier_uniform_(self.out_v_proj.weight) + self.out_v_proj.bias.data.fill_(0) + nn.init.xavier_uniform_(self.out_l_proj.weight) + self.out_l_proj.bias.data.fill_(0) + + def forward(self, v, l, attention_mask_v=None, attention_mask_l=None): + """_summary_ + + Args: + v (_type_): bs, n_img, dim + l (_type_): bs, n_text, dim + attention_mask_v (_type_, optional): _description_. bs, n_img + attention_mask_l (_type_, optional): _description_. bs, n_text + + Returns: + _type_: _description_ + """ + # if os.environ.get('IPDB_SHILONG_DEBUG', None) == 'INFO': + # import ipdb; ipdb.set_trace() + bsz, tgt_len, _ = v.size() + + query_states = self.v_proj(v) * self.scale + key_states = self._shape(self.l_proj(l), -1, bsz) + value_v_states = self._shape(self.values_v_proj(v), -1, bsz) + value_l_states = self._shape(self.values_l_proj(l), -1, bsz) + + proj_shape = (bsz * self.num_heads, -1, self.head_dim) + query_states = self._shape(query_states, tgt_len, bsz).view(*proj_shape) + key_states = key_states.view(*proj_shape) + value_v_states = value_v_states.view(*proj_shape) + value_l_states = value_l_states.view(*proj_shape) + + src_len = key_states.size(1) + attn_weights = torch.bmm(query_states, key_states.transpose(1, 2)) # bs*nhead, nimg, ntxt + + if attn_weights.size() != (bsz * self.num_heads, tgt_len, src_len): + raise ValueError( + f"Attention weights should be of size {(bsz * self.num_heads, tgt_len, src_len)}, but is {attn_weights.size()}" + ) + + if self.stable_softmax_2d: + attn_weights = attn_weights - attn_weights.max() + + if self.clamp_min_for_underflow: + attn_weights = torch.clamp( + attn_weights, min=-50000 + ) # Do not increase -50000, data type half has quite limited range + if self.clamp_max_for_overflow: + attn_weights = torch.clamp( + attn_weights, max=50000 + ) # Do not increase 50000, data type half has quite limited range + + attn_weights_T = attn_weights.transpose(1, 2) + attn_weights_l = attn_weights_T - torch.max(attn_weights_T, dim=-1, keepdim=True)[0] + if self.clamp_min_for_underflow: + attn_weights_l = torch.clamp( + attn_weights_l, min=-50000 + ) # Do not increase -50000, data type half has quite limited range + if self.clamp_max_for_overflow: + attn_weights_l = torch.clamp( + attn_weights_l, max=50000 + ) # Do not increase 50000, data type half has quite limited range + + # mask vison for language + if attention_mask_v is not None: + attention_mask_v = ( + attention_mask_v[:, None, None, :].repeat(1, self.num_heads, 1, 1).flatten(0, 1) + ) + attn_weights_l.masked_fill_(attention_mask_v, float("-inf")) + + attn_weights_l = attn_weights_l.softmax(dim=-1) + + # mask language for vision + if attention_mask_l is not None: + attention_mask_l = ( + attention_mask_l[:, None, None, :].repeat(1, self.num_heads, 1, 1).flatten(0, 1) + ) + attn_weights.masked_fill_(attention_mask_l, float("-inf")) + attn_weights_v = attn_weights.softmax(dim=-1) + + attn_probs_v = F.dropout(attn_weights_v, p=self.dropout, training=self.training) + attn_probs_l = F.dropout(attn_weights_l, p=self.dropout, training=self.training) + + attn_output_v = torch.bmm(attn_probs_v, value_l_states) + attn_output_l = torch.bmm(attn_probs_l, value_v_states) + + if attn_output_v.size() != (bsz * self.num_heads, tgt_len, self.head_dim): + raise ValueError( + f"`attn_output_v` should be of size {(bsz, self.num_heads, tgt_len, self.head_dim)}, but is {attn_output_v.size()}" + ) + + if attn_output_l.size() != (bsz * self.num_heads, src_len, self.head_dim): + raise ValueError( + f"`attn_output_l` should be of size {(bsz, self.num_heads, src_len, self.head_dim)}, but is {attn_output_l.size()}" + ) + + attn_output_v = attn_output_v.view(bsz, self.num_heads, tgt_len, self.head_dim) + attn_output_v = attn_output_v.transpose(1, 2) + attn_output_v = attn_output_v.reshape(bsz, tgt_len, self.embed_dim) + + attn_output_l = attn_output_l.view(bsz, self.num_heads, src_len, self.head_dim) + attn_output_l = attn_output_l.transpose(1, 2) + attn_output_l = attn_output_l.reshape(bsz, src_len, self.embed_dim) + + attn_output_v = self.out_v_proj(attn_output_v) + attn_output_l = self.out_l_proj(attn_output_l) + + return attn_output_v, attn_output_l + + +# Bi-Direction MHA (text->image, image->text) +class BiAttentionBlock(nn.Module): + def __init__( + self, + v_dim, + l_dim, + embed_dim, + num_heads, + dropout=0.1, + drop_path=0.0, + init_values=1e-4, + cfg=None, + ): + """ + Inputs: + embed_dim - Dimensionality of input and attention feature vectors + hidden_dim - Dimensionality of hidden layer in feed-forward network + (usually 2-4x larger than embed_dim) + num_heads - Number of heads to use in the Multi-Head Attention block + dropout - Amount of dropout to apply in the feed-forward network + """ + super(BiAttentionBlock, self).__init__() + + # pre layer norm + self.layer_norm_v = nn.LayerNorm(v_dim) + self.layer_norm_l = nn.LayerNorm(l_dim) + self.attn = BiMultiHeadAttention( + v_dim=v_dim, l_dim=l_dim, embed_dim=embed_dim, num_heads=num_heads, dropout=dropout + ) + + # add layer scale for training stability + self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity() + self.gamma_v = nn.Parameter(init_values * torch.ones((v_dim)), requires_grad=True) + self.gamma_l = nn.Parameter(init_values * torch.ones((l_dim)), requires_grad=True) + + def forward(self, v, l, attention_mask_v=None, attention_mask_l=None): + v = self.layer_norm_v(v) + l = self.layer_norm_l(l) + delta_v, delta_l = self.attn( + v, l, attention_mask_v=attention_mask_v, attention_mask_l=attention_mask_l + ) + # v, l = v + delta_v, l + delta_l + v = v + self.drop_path(self.gamma_v * delta_v) + l = l + self.drop_path(self.gamma_l * delta_l) + return v, l + + # def forward(self, v:List[torch.Tensor], l, attention_mask_v=None, attention_mask_l=None) + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/groundingdino.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/groundingdino.py new file mode 100644 index 0000000..14bebd5 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/groundingdino.py @@ -0,0 +1,386 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Conditional DETR model and criterion classes. +# Copyright (c) 2021 Microsoft. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Modified from DETR (https://github.com/facebookresearch/detr) +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. +# ------------------------------------------------------------------------ +# Modified from Deformable DETR (https://github.com/fundamentalvision/Deformable-DETR) +# Copyright (c) 2020 SenseTime. All Rights Reserved. +# ------------------------------------------------------------------------ +import copy +from typing import List + +import torch +import torch.nn.functional as F +from torch import nn + +from simple_syrup.third_party.groundingdino_runtime.util import get_tokenlizer +from simple_syrup.third_party.groundingdino_runtime.util.misc import ( + NestedTensor, + inverse_sigmoid, + nested_tensor_from_tensor_list, +) + +from ..registry import MODULE_BUILD_FUNCS +from .backbone import build_backbone +from .bertwarper import ( + BertModelWarper, + generate_masks_with_special_tokens_and_transfer_map, +) +from .transformer import build_transformer +from .utils import MLP, ContrastiveEmbed + + +class GroundingDINO(nn.Module): + """This is the Cross-Attention Detector module that performs object detection""" + + def __init__( + self, + backbone, + transformer, + num_queries, + aux_loss=False, + iter_update=False, + query_dim=2, + num_feature_levels=1, + nheads=8, + # two stage + two_stage_type="no", # ['no', 'standard'] + dec_pred_bbox_embed_share=True, + two_stage_class_embed_share=True, + two_stage_bbox_embed_share=True, + num_patterns=0, + dn_number=100, + dn_box_noise_scale=0.4, + dn_label_noise_ratio=0.5, + dn_labelbook_size=100, + text_encoder_type="bert-base-uncased", + sub_sentence_present=True, + max_text_len=256, + ): + """Initializes the model. + Parameters: + backbone: torch module of the backbone to be used. See backbone.py + transformer: torch module of the transformer architecture. See transformer.py + num_queries: number of object queries, ie detection slot. This is the maximal number of objects + Conditional DETR can detect in a single image. For COCO, we recommend 100 queries. + aux_loss: True if auxiliary decoding losses (loss at each decoder layer) are to be used. + """ + super().__init__() + self.num_queries = num_queries + self.transformer = transformer + self.hidden_dim = hidden_dim = transformer.d_model + self.num_feature_levels = num_feature_levels + self.nheads = nheads + self.max_text_len = 256 + self.sub_sentence_present = sub_sentence_present + + # setting query dim + self.query_dim = query_dim + assert query_dim == 4 + + # for dn training + self.num_patterns = num_patterns + self.dn_number = dn_number + self.dn_box_noise_scale = dn_box_noise_scale + self.dn_label_noise_ratio = dn_label_noise_ratio + self.dn_labelbook_size = dn_labelbook_size + + # bert + self.tokenizer = get_tokenlizer.get_tokenlizer(text_encoder_type) + self.bert = get_tokenlizer.get_pretrained_language_model(text_encoder_type) + self.bert.pooler.dense.weight.requires_grad_(False) + self.bert.pooler.dense.bias.requires_grad_(False) + self.bert = BertModelWarper(bert_model=self.bert) + + self.feat_map = nn.Linear(self.bert.config.hidden_size, self.hidden_dim, bias=True) + nn.init.constant_(self.feat_map.bias.data, 0) + nn.init.xavier_uniform_(self.feat_map.weight.data) + # freeze + + # special tokens + self.specical_tokens = self.tokenizer.convert_tokens_to_ids(["[CLS]", "[SEP]", ".", "?"]) + + # prepare input projection layers + if num_feature_levels > 1: + num_backbone_outs = len(backbone.num_channels) + input_proj_list = [] + for _ in range(num_backbone_outs): + in_channels = backbone.num_channels[_] + input_proj_list.append( + nn.Sequential( + nn.Conv2d(in_channels, hidden_dim, kernel_size=1), + nn.GroupNorm(32, hidden_dim), + ) + ) + for _ in range(num_feature_levels - num_backbone_outs): + input_proj_list.append( + nn.Sequential( + nn.Conv2d(in_channels, hidden_dim, kernel_size=3, stride=2, padding=1), + nn.GroupNorm(32, hidden_dim), + ) + ) + in_channels = hidden_dim + self.input_proj = nn.ModuleList(input_proj_list) + else: + assert two_stage_type == "no", "two_stage_type should be no if num_feature_levels=1 !!!" + self.input_proj = nn.ModuleList( + [ + nn.Sequential( + nn.Conv2d(backbone.num_channels[-1], hidden_dim, kernel_size=1), + nn.GroupNorm(32, hidden_dim), + ) + ] + ) + + self.backbone = backbone + self.aux_loss = aux_loss + self.box_pred_damping = box_pred_damping = None + + self.iter_update = iter_update + assert iter_update, "Why not iter_update?" + + # prepare pred layers + self.dec_pred_bbox_embed_share = dec_pred_bbox_embed_share + # prepare class & box embed + _class_embed = ContrastiveEmbed() + + _bbox_embed = MLP(hidden_dim, hidden_dim, 4, 3) + nn.init.constant_(_bbox_embed.layers[-1].weight.data, 0) + nn.init.constant_(_bbox_embed.layers[-1].bias.data, 0) + + if dec_pred_bbox_embed_share: + box_embed_layerlist = [_bbox_embed for i in range(transformer.num_decoder_layers)] + else: + box_embed_layerlist = [ + copy.deepcopy(_bbox_embed) for i in range(transformer.num_decoder_layers) + ] + class_embed_layerlist = [_class_embed for i in range(transformer.num_decoder_layers)] + self.bbox_embed = nn.ModuleList(box_embed_layerlist) + self.class_embed = nn.ModuleList(class_embed_layerlist) + self.transformer.decoder.bbox_embed = self.bbox_embed + self.transformer.decoder.class_embed = self.class_embed + + # two stage + self.two_stage_type = two_stage_type + assert two_stage_type in ["no", "standard"], "unknown param {} of two_stage_type".format( + two_stage_type + ) + if two_stage_type != "no": + if two_stage_bbox_embed_share: + assert dec_pred_bbox_embed_share + self.transformer.enc_out_bbox_embed = _bbox_embed + else: + self.transformer.enc_out_bbox_embed = copy.deepcopy(_bbox_embed) + + if two_stage_class_embed_share: + assert dec_pred_bbox_embed_share + self.transformer.enc_out_class_embed = _class_embed + else: + self.transformer.enc_out_class_embed = copy.deepcopy(_class_embed) + + self.refpoint_embed = None + + self._reset_parameters() + + def _reset_parameters(self): + # init input_proj + for proj in self.input_proj: + nn.init.xavier_uniform_(proj[0].weight, gain=1) + nn.init.constant_(proj[0].bias, 0) + + def init_ref_points(self, use_num_queries): + self.refpoint_embed = nn.Embedding(use_num_queries, self.query_dim) + + def forward(self, samples: NestedTensor, targets: List = None, **kw): + """The forward expects a NestedTensor, which consists of: + - samples.tensor: batched images, of shape [batch_size x 3 x H x W] + - samples.mask: a binary mask of shape [batch_size x H x W], containing 1 on padded pixels + + It returns a dict with the following elements: + - "pred_logits": the classification logits (including no-object) for all queries. + Shape= [batch_size x num_queries x num_classes] + - "pred_boxes": The normalized boxes coordinates for all queries, represented as + (center_x, center_y, width, height). These values are normalized in [0, 1], + relative to the size of each individual image (disregarding possible padding). + See PostProcess for information on how to retrieve the unnormalized bounding box. + - "aux_outputs": Optional, only returned when auxilary losses are activated. It is a list of + dictionnaries containing the two above keys for each decoder layer. + """ + if targets is None: + captions = kw["captions"] + else: + captions = [t["caption"] for t in targets] + len(captions) + + # encoder texts + tokenized = self.tokenizer(captions, padding="longest", return_tensors="pt").to( + samples.device + ) + ( + text_self_attention_masks, + position_ids, + cate_to_token_mask_list, + ) = generate_masks_with_special_tokens_and_transfer_map( + tokenized, self.specical_tokens, self.tokenizer + ) + + if text_self_attention_masks.shape[1] > self.max_text_len: + text_self_attention_masks = text_self_attention_masks[ + :, : self.max_text_len, : self.max_text_len + ] + position_ids = position_ids[:, : self.max_text_len] + tokenized["input_ids"] = tokenized["input_ids"][:, : self.max_text_len] + tokenized["attention_mask"] = tokenized["attention_mask"][:, : self.max_text_len] + tokenized["token_type_ids"] = tokenized["token_type_ids"][:, : self.max_text_len] + + # extract text embeddings + if self.sub_sentence_present: + tokenized_for_encoder = {k: v for k, v in tokenized.items() if k != "attention_mask"} + tokenized_for_encoder["attention_mask"] = text_self_attention_masks + tokenized_for_encoder["position_ids"] = position_ids + else: + # import ipdb; ipdb.set_trace() + tokenized_for_encoder = tokenized + + bert_output = self.bert(**tokenized_for_encoder) # bs, 195, 768 + + encoded_text = self.feat_map(bert_output["last_hidden_state"]) # bs, 195, d_model + text_token_mask = tokenized.attention_mask.bool() # bs, 195 + # text_token_mask: True for nomask, False for mask + # text_self_attention_masks: True for nomask, False for mask + + if encoded_text.shape[1] > self.max_text_len: + encoded_text = encoded_text[:, : self.max_text_len, :] + text_token_mask = text_token_mask[:, : self.max_text_len] + position_ids = position_ids[:, : self.max_text_len] + text_self_attention_masks = text_self_attention_masks[ + :, : self.max_text_len, : self.max_text_len + ] + + text_dict = { + "encoded_text": encoded_text, # bs, 195, d_model + "text_token_mask": text_token_mask, # bs, 195 + "position_ids": position_ids, # bs, 195 + "text_self_attention_masks": text_self_attention_masks, # bs, 195,195 + } + + # import ipdb; ipdb.set_trace() + + if isinstance(samples, (list, torch.Tensor)): + samples = nested_tensor_from_tensor_list(samples) + features, poss = self.backbone(samples) + + srcs = [] + masks = [] + for l, feat in enumerate(features): + src, mask = feat.decompose() + srcs.append(self.input_proj[l](src)) + masks.append(mask) + assert mask is not None + if self.num_feature_levels > len(srcs): + _len_srcs = len(srcs) + for l in range(_len_srcs, self.num_feature_levels): + if l == _len_srcs: + src = self.input_proj[l](features[-1].tensors) + else: + src = self.input_proj[l](srcs[-1]) + m = samples.mask + mask = F.interpolate(m[None].float(), size=src.shape[-2:]).to(torch.bool)[0] + pos_l = self.backbone[1](NestedTensor(src, mask)).to(src.dtype) + srcs.append(src) + masks.append(mask) + poss.append(pos_l) + + input_query_bbox = input_query_label = attn_mask = dn_meta = None + hs, reference, hs_enc, ref_enc, init_box_proposal = self.transformer( + srcs, masks, input_query_bbox, poss, input_query_label, attn_mask, text_dict + ) + + # deformable-detr-like anchor update + outputs_coord_list = [] + for dec_lid, (layer_ref_sig, layer_bbox_embed, layer_hs) in enumerate( + zip(reference[:-1], self.bbox_embed, hs) + ): + layer_delta_unsig = layer_bbox_embed(layer_hs) + layer_outputs_unsig = layer_delta_unsig + inverse_sigmoid(layer_ref_sig) + layer_outputs_unsig = layer_outputs_unsig.sigmoid() + outputs_coord_list.append(layer_outputs_unsig) + outputs_coord_list = torch.stack(outputs_coord_list) + + # output + outputs_class = torch.stack( + [ + layer_cls_embed(layer_hs, text_dict) + for layer_cls_embed, layer_hs in zip(self.class_embed, hs) + ] + ) + out = {"pred_logits": outputs_class[-1], "pred_boxes": outputs_coord_list[-1]} + + # # for intermediate outputs + # if self.aux_loss: + # out['aux_outputs'] = self._set_aux_loss(outputs_class, outputs_coord_list) + + # # for encoder output + # if hs_enc is not None: + # # prepare intermediate outputs + # interm_coord = ref_enc[-1] + # interm_class = self.transformer.enc_out_class_embed(hs_enc[-1], text_dict) + # out['interm_outputs'] = {'pred_logits': interm_class, 'pred_boxes': interm_coord} + # out['interm_outputs_for_matching_pre'] = {'pred_logits': interm_class, 'pred_boxes': init_box_proposal} + + return out + + @torch.jit.unused + def _set_aux_loss(self, outputs_class, outputs_coord): + # this is a workaround to make torchscript happy, as torchscript + # doesn't support dictionary with non-homogeneous values, such + # as a dict having both a Tensor and a list. + return [ + {"pred_logits": a, "pred_boxes": b} + for a, b in zip(outputs_class[:-1], outputs_coord[:-1]) + ] + + +@MODULE_BUILD_FUNCS.registe_with_name(module_name="groundingdino") +def build_groundingdino(args): + + backbone = build_backbone(args) + transformer = build_transformer(args) + + dn_labelbook_size = args.dn_labelbook_size + dec_pred_bbox_embed_share = args.dec_pred_bbox_embed_share + sub_sentence_present = args.sub_sentence_present + + model = GroundingDINO( + backbone, + transformer, + num_queries=args.num_queries, + aux_loss=True, + iter_update=True, + query_dim=4, + num_feature_levels=args.num_feature_levels, + nheads=args.nheads, + dec_pred_bbox_embed_share=dec_pred_bbox_embed_share, + two_stage_type=args.two_stage_type, + two_stage_bbox_embed_share=args.two_stage_bbox_embed_share, + two_stage_class_embed_share=args.two_stage_class_embed_share, + num_patterns=args.num_patterns, + dn_number=0, + dn_box_noise_scale=args.dn_box_noise_scale, + dn_label_noise_ratio=args.dn_label_noise_ratio, + dn_labelbook_size=dn_labelbook_size, + text_encoder_type=args.text_encoder_type, + sub_sentence_present=sub_sentence_present, + max_text_len=args.max_text_len, + ) + + return model + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/ms_deform_attn.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/ms_deform_attn.py new file mode 100644 index 0000000..64fde1f --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/ms_deform_attn.py @@ -0,0 +1,335 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Deformable DETR +# Copyright (c) 2020 SenseTime. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------------------------------ +# Modified from: +# https://github.com/fundamentalvision/Deformable-DETR/blob/main/models/ops/functions/ms_deform_attn_func.py +# https://github.com/fundamentalvision/Deformable-DETR/blob/main/models/ops/modules/ms_deform_attn.py +# https://github.com/open-mmlab/mmcv/blob/master/mmcv/ops/multi_scale_deform_attn.py +# ------------------------------------------------------------------------------------------------ + +import math +import warnings +from typing import Optional + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.nn.init import constant_, xavier_uniform_ + + +# helpers +def _is_power_of_2(n): + if (not isinstance(n, int)) or (n < 0): + raise ValueError("invalid input for _is_power_of_2: {} (type: {})".format(n, type(n))) + return (n & (n - 1) == 0) and n != 0 + + +def multi_scale_deformable_attn_pytorch( + value: torch.Tensor, + value_spatial_shapes: torch.Tensor, + sampling_locations: torch.Tensor, + attention_weights: torch.Tensor, +) -> torch.Tensor: + + bs, _, num_heads, embed_dims = value.shape + _, num_queries, num_heads, num_levels, num_points, _ = sampling_locations.shape + value_list = value.split([H_ * W_ for H_, W_ in value_spatial_shapes], dim=1) + sampling_grids = 2 * sampling_locations - 1 + sampling_value_list = [] + for level, (H_, W_) in enumerate(value_spatial_shapes): + # bs, H_*W_, num_heads, embed_dims -> + # bs, H_*W_, num_heads*embed_dims -> + # bs, num_heads*embed_dims, H_*W_ -> + # bs*num_heads, embed_dims, H_, W_ + value_l_ = ( + value_list[level].flatten(2).transpose(1, 2).reshape(bs * num_heads, embed_dims, H_, W_) + ) + # bs, num_queries, num_heads, num_points, 2 -> + # bs, num_heads, num_queries, num_points, 2 -> + # bs*num_heads, num_queries, num_points, 2 + sampling_grid_l_ = sampling_grids[:, :, :, level].transpose(1, 2).flatten(0, 1) + # bs*num_heads, embed_dims, num_queries, num_points + sampling_value_l_ = F.grid_sample( + value_l_, sampling_grid_l_, mode="bilinear", padding_mode="zeros", align_corners=False + ) + sampling_value_list.append(sampling_value_l_) + # (bs, num_queries, num_heads, num_levels, num_points) -> + # (bs, num_heads, num_queries, num_levels, num_points) -> + # (bs, num_heads, 1, num_queries, num_levels*num_points) + attention_weights = attention_weights.transpose(1, 2).reshape( + bs * num_heads, 1, num_queries, num_levels * num_points + ) + output = ( + (torch.stack(sampling_value_list, dim=-2).flatten(-2) * attention_weights) + .sum(-1) + .view(bs, num_heads * embed_dims, num_queries) + ) + return output.transpose(1, 2).contiguous() + + +class MultiScaleDeformableAttention(nn.Module): + """Multi-Scale Deformable Attention Module used in Deformable-DETR + + `Deformable DETR: Deformable Transformers for End-to-End Object Detection. + `_. + + Args: + embed_dim (int): The embedding dimension of Attention. Default: 256. + num_heads (int): The number of attention heads. Default: 8. + num_levels (int): The number of feature map used in Attention. Default: 4. + num_points (int): The number of sampling points for each query + in each head. Default: 4. + img2col_steps (int): The step used in image_to_column. Defualt: 64. + dropout (float): Dropout layer used in output. Default: 0.1. + batch_first (bool): if ``True``, then the input and output tensor will be + provided as `(bs, n, embed_dim)`. Default: False. `(n, bs, embed_dim)` + """ + + def __init__( + self, + embed_dim: int = 256, + num_heads: int = 8, + num_levels: int = 4, + num_points: int = 4, + img2col_step: int = 64, + batch_first: bool = False, + ): + super().__init__() + if embed_dim % num_heads != 0: + raise ValueError( + "embed_dim must be divisible by num_heads, but got {} and {}".format( + embed_dim, num_heads + ) + ) + head_dim = embed_dim // num_heads + + self.batch_first = batch_first + + if not _is_power_of_2(head_dim): + warnings.warn( + """ + You'd better set d_model in MSDeformAttn to make sure that + each dim of the attention head a power of 2, which is more efficient. + """ + ) + + self.im2col_step = img2col_step + self.embed_dim = embed_dim + self.num_heads = num_heads + self.num_levels = num_levels + self.num_points = num_points + self.sampling_offsets = nn.Linear(embed_dim, num_heads * num_levels * num_points * 2) + self.attention_weights = nn.Linear(embed_dim, num_heads * num_levels * num_points) + self.value_proj = nn.Linear(embed_dim, embed_dim) + self.output_proj = nn.Linear(embed_dim, embed_dim) + + self.init_weights() + + def _reset_parameters(self): + return self.init_weights() + + def init_weights(self): + """ + Default initialization for Parameters of Module. + """ + constant_(self.sampling_offsets.weight.data, 0.0) + thetas = torch.arange(self.num_heads, dtype=torch.float32) * ( + 2.0 * math.pi / self.num_heads + ) + grid_init = torch.stack([thetas.cos(), thetas.sin()], -1) + grid_init = ( + (grid_init / grid_init.abs().max(-1, keepdim=True)[0]) + .view(self.num_heads, 1, 1, 2) + .repeat(1, self.num_levels, self.num_points, 1) + ) + for i in range(self.num_points): + grid_init[:, :, i, :] *= i + 1 + with torch.no_grad(): + self.sampling_offsets.bias = nn.Parameter(grid_init.view(-1)) + constant_(self.attention_weights.weight.data, 0.0) + constant_(self.attention_weights.bias.data, 0.0) + xavier_uniform_(self.value_proj.weight.data) + constant_(self.value_proj.bias.data, 0.0) + xavier_uniform_(self.output_proj.weight.data) + constant_(self.output_proj.bias.data, 0.0) + + def freeze_sampling_offsets(self): + print("Freeze sampling offsets") + self.sampling_offsets.weight.requires_grad = False + self.sampling_offsets.bias.requires_grad = False + + def freeze_attention_weights(self): + print("Freeze attention weights") + self.attention_weights.weight.requires_grad = False + self.attention_weights.bias.requires_grad = False + + def forward( + self, + query: torch.Tensor, + key: Optional[torch.Tensor] = None, + value: Optional[torch.Tensor] = None, + query_pos: Optional[torch.Tensor] = None, + key_padding_mask: Optional[torch.Tensor] = None, + reference_points: Optional[torch.Tensor] = None, + spatial_shapes: Optional[torch.Tensor] = None, + level_start_index: Optional[torch.Tensor] = None, + **kwargs + ) -> torch.Tensor: + + """Forward Function of MultiScaleDeformableAttention + + Args: + query (torch.Tensor): Query embeddings with shape + `(num_query, bs, embed_dim)` + key (torch.Tensor): Key embeddings with shape + `(num_key, bs, embed_dim)` + value (torch.Tensor): Value embeddings with shape + `(num_key, bs, embed_dim)` + query_pos (torch.Tensor): The position embedding for `query`. Default: None. + key_padding_mask (torch.Tensor): ByteTensor for `query`, with shape `(bs, num_key)`, + indicating which elements within `key` to be ignored in attention. + reference_points (torch.Tensor): The normalized reference points + with shape `(bs, num_query, num_levels, 2)`, + all elements is range in [0, 1], top-left (0, 0), + bottom-right (1, 1), including padding are. + or `(N, Length_{query}, num_levels, 4)`, add additional + two dimensions `(h, w)` to form reference boxes. + spatial_shapes (torch.Tensor): Spatial shape of features in different levels. + With shape `(num_levels, 2)`, last dimension represents `(h, w)`. + level_start_index (torch.Tensor): The start index of each level. A tensor with + shape `(num_levels, )` which can be represented as + `[0, h_0 * w_0, h_0 * w_0 + h_1 * w_1, ...]`. + + Returns: + torch.Tensor: forward results with shape `(num_query, bs, embed_dim)` + """ + + if value is None: + value = query + + if query_pos is not None: + query = query + query_pos + + if not self.batch_first: + # change to (bs, num_query ,embed_dims) + query = query.permute(1, 0, 2) + value = value.permute(1, 0, 2) + + bs, num_query, _ = query.shape + bs, num_value, _ = value.shape + + assert (spatial_shapes[:, 0] * spatial_shapes[:, 1]).sum() == num_value + + value = self.value_proj(value) + if key_padding_mask is not None: + value = value.masked_fill(key_padding_mask[..., None], float(0)) + value = value.view(bs, num_value, self.num_heads, -1) + sampling_offsets = self.sampling_offsets(query).view( + bs, num_query, self.num_heads, self.num_levels, self.num_points, 2 + ) + attention_weights = self.attention_weights(query).view( + bs, num_query, self.num_heads, self.num_levels * self.num_points + ) + attention_weights = attention_weights.softmax(-1) + attention_weights = attention_weights.view( + bs, + num_query, + self.num_heads, + self.num_levels, + self.num_points, + ) + + # bs, num_query, num_heads, num_levels, num_points, 2 + if reference_points.shape[-1] == 2: + offset_normalizer = torch.stack([spatial_shapes[..., 1], spatial_shapes[..., 0]], -1) + sampling_locations = ( + reference_points[:, :, None, :, None, :] + + sampling_offsets / offset_normalizer[None, None, None, :, None, :] + ) + elif reference_points.shape[-1] == 4: + sampling_locations = ( + reference_points[:, :, None, :, None, :2] + + sampling_offsets + / self.num_points + * reference_points[:, :, None, :, None, 2:] + * 0.5 + ) + else: + raise ValueError( + "Last dim of reference_points must be 2 or 4, but get {} instead.".format( + reference_points.shape[-1] + ) + ) + + output = multi_scale_deformable_attn_pytorch( + value, spatial_shapes, sampling_locations, attention_weights + ) + + output = self.output_proj(output) + + if not self.batch_first: + output = output.permute(1, 0, 2) + + return output + + +def create_dummy_class(klass, dependency, message=""): + """ + When a dependency of a class is not available, create a dummy class which throws ImportError + when used. + + Args: + klass (str): name of the class. + dependency (str): name of the dependency. + message: extra message to print + Returns: + class: a class object + """ + err = "Cannot import '{}', therefore '{}' is not available.".format(dependency, klass) + if message: + err = err + " " + message + + class _DummyMetaClass(type): + # throw error on class attribute access + def __getattr__(_, __): # noqa: B902 + raise ImportError(err) + + class _Dummy(object, metaclass=_DummyMetaClass): + # throw error on constructor + def __init__(self, *args, **kwargs): + raise ImportError(err) + + return _Dummy + + +def create_dummy_func(func, dependency, message=""): + """ + When a dependency of a function is not available, create a dummy function which throws + ImportError when used. + + Args: + func (str): name of the function. + dependency (str or list[str]): name(s) of the dependency. + message: extra message to print + Returns: + function: a function object + """ + err = "Cannot import '{}', therefore '{}' is not available.".format(dependency, func) + if message: + err = err + " " + message + + if isinstance(dependency, (list, tuple)): + dependency = ",".join(dependency) + + def _dummy(*args, **kwargs): + raise ImportError(err) + + return _dummy + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer.py new file mode 100644 index 0000000..4549186 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer.py @@ -0,0 +1,960 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# DINO +# Copyright (c) 2022 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Conditional DETR Transformer class. +# Copyright (c) 2021 Microsoft. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Modified from DETR (https://github.com/facebookresearch/detr) +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. +# ------------------------------------------------------------------------ + +from typing import Optional + +import torch +import torch.utils.checkpoint as checkpoint +from torch import Tensor, nn + +from simple_syrup.third_party.groundingdino_runtime.util.misc import inverse_sigmoid + +from .fuse_modules import BiAttentionBlock +from .ms_deform_attn import MultiScaleDeformableAttention as MSDeformAttn +from .transformer_vanilla import TransformerEncoderLayer +from .utils import ( + MLP, + _get_activation_fn, + _get_clones, + gen_encoder_output_proposals, + gen_sineembed_for_position, + get_sine_pos_embed, +) + + +class Transformer(nn.Module): + def __init__( + self, + d_model=256, + nhead=8, + num_queries=300, + num_encoder_layers=6, + num_unicoder_layers=0, + num_decoder_layers=6, + dim_feedforward=2048, + dropout=0.0, + activation="relu", + normalize_before=False, + return_intermediate_dec=False, + query_dim=4, + num_patterns=0, + # for deformable encoder + num_feature_levels=1, + enc_n_points=4, + dec_n_points=4, + # init query + learnable_tgt_init=False, + # two stage + two_stage_type="no", # ['no', 'standard', 'early', 'combine', 'enceachlayer', 'enclayer1'] + embed_init_tgt=False, + # for text + use_text_enhancer=False, + use_fusion_layer=False, + use_checkpoint=False, + use_transformer_ckpt=False, + use_text_cross_attention=False, + text_dropout=0.1, + fusion_dropout=0.1, + fusion_droppath=0.0, + ): + super().__init__() + self.num_feature_levels = num_feature_levels + self.num_encoder_layers = num_encoder_layers + self.num_unicoder_layers = num_unicoder_layers + self.num_decoder_layers = num_decoder_layers + self.num_queries = num_queries + assert query_dim == 4 + + # choose encoder layer type + encoder_layer = DeformableTransformerEncoderLayer( + d_model, dim_feedforward, dropout, activation, num_feature_levels, nhead, enc_n_points + ) + + if use_text_enhancer: + text_enhance_layer = TransformerEncoderLayer( + d_model=d_model, + nhead=nhead // 2, + dim_feedforward=dim_feedforward // 2, + dropout=text_dropout, + ) + else: + text_enhance_layer = None + + if use_fusion_layer: + feature_fusion_layer = BiAttentionBlock( + v_dim=d_model, + l_dim=d_model, + embed_dim=dim_feedforward // 2, + num_heads=nhead // 2, + dropout=fusion_dropout, + drop_path=fusion_droppath, + ) + else: + feature_fusion_layer = None + + encoder_norm = nn.LayerNorm(d_model) if normalize_before else None + assert encoder_norm is None + self.encoder = TransformerEncoder( + encoder_layer, + num_encoder_layers, + d_model=d_model, + num_queries=num_queries, + text_enhance_layer=text_enhance_layer, + feature_fusion_layer=feature_fusion_layer, + use_checkpoint=use_checkpoint, + use_transformer_ckpt=use_transformer_ckpt, + ) + + # choose decoder layer type + decoder_layer = DeformableTransformerDecoderLayer( + d_model, + dim_feedforward, + dropout, + activation, + num_feature_levels, + nhead, + dec_n_points, + use_text_cross_attention=use_text_cross_attention, + ) + + decoder_norm = nn.LayerNorm(d_model) + self.decoder = TransformerDecoder( + decoder_layer, + num_decoder_layers, + decoder_norm, + return_intermediate=return_intermediate_dec, + d_model=d_model, + query_dim=query_dim, + num_feature_levels=num_feature_levels, + ) + + self.d_model = d_model + self.nhead = nhead + self.dec_layers = num_decoder_layers + self.num_queries = num_queries # useful for single stage model only + self.num_patterns = num_patterns + if not isinstance(num_patterns, int): + Warning("num_patterns should be int but {}".format(type(num_patterns))) + self.num_patterns = 0 + + if num_feature_levels > 1: + if self.num_encoder_layers > 0: + self.level_embed = nn.Parameter(torch.Tensor(num_feature_levels, d_model)) + else: + self.level_embed = None + + self.learnable_tgt_init = learnable_tgt_init + assert learnable_tgt_init, "why not learnable_tgt_init" + self.embed_init_tgt = embed_init_tgt + if (two_stage_type != "no" and embed_init_tgt) or (two_stage_type == "no"): + self.tgt_embed = nn.Embedding(self.num_queries, d_model) + nn.init.normal_(self.tgt_embed.weight.data) + else: + self.tgt_embed = None + + # for two stage + self.two_stage_type = two_stage_type + assert two_stage_type in ["no", "standard"], "unknown param {} of two_stage_type".format( + two_stage_type + ) + if two_stage_type == "standard": + # anchor selection at the output of encoder + self.enc_output = nn.Linear(d_model, d_model) + self.enc_output_norm = nn.LayerNorm(d_model) + self.two_stage_wh_embedding = None + + if two_stage_type == "no": + self.init_ref_points(num_queries) # init self.refpoint_embed + + self.enc_out_class_embed = None + self.enc_out_bbox_embed = None + + self._reset_parameters() + + def _reset_parameters(self): + for p in self.parameters(): + if p.dim() > 1: + nn.init.xavier_uniform_(p) + for m in self.modules(): + if isinstance(m, MSDeformAttn): + m._reset_parameters() + if self.num_feature_levels > 1 and self.level_embed is not None: + nn.init.normal_(self.level_embed) + + def get_valid_ratio(self, mask): + _, H, W = mask.shape + valid_H = torch.sum(~mask[:, :, 0], 1) + valid_W = torch.sum(~mask[:, 0, :], 1) + valid_ratio_h = valid_H.float() / H + valid_ratio_w = valid_W.float() / W + valid_ratio = torch.stack([valid_ratio_w, valid_ratio_h], -1) + return valid_ratio + + def init_ref_points(self, use_num_queries): + self.refpoint_embed = nn.Embedding(use_num_queries, 4) + + def forward(self, srcs, masks, refpoint_embed, pos_embeds, tgt, attn_mask=None, text_dict=None): + """ + Input: + - srcs: List of multi features [bs, ci, hi, wi] + - masks: List of multi masks [bs, hi, wi] + - refpoint_embed: [bs, num_dn, 4]. None in infer + - pos_embeds: List of multi pos embeds [bs, ci, hi, wi] + - tgt: [bs, num_dn, d_model]. None in infer + + """ + # prepare input for encoder + src_flatten = [] + mask_flatten = [] + lvl_pos_embed_flatten = [] + spatial_shapes = [] + for lvl, (src, mask, pos_embed) in enumerate(zip(srcs, masks, pos_embeds)): + bs, c, h, w = src.shape + spatial_shape = (h, w) + spatial_shapes.append(spatial_shape) + + src = src.flatten(2).transpose(1, 2) # bs, hw, c + mask = mask.flatten(1) # bs, hw + pos_embed = pos_embed.flatten(2).transpose(1, 2) # bs, hw, c + if self.num_feature_levels > 1 and self.level_embed is not None: + lvl_pos_embed = pos_embed + self.level_embed[lvl].view(1, 1, -1) + else: + lvl_pos_embed = pos_embed + lvl_pos_embed_flatten.append(lvl_pos_embed) + src_flatten.append(src) + mask_flatten.append(mask) + src_flatten = torch.cat(src_flatten, 1) # bs, \sum{hxw}, c + mask_flatten = torch.cat(mask_flatten, 1) # bs, \sum{hxw} + lvl_pos_embed_flatten = torch.cat(lvl_pos_embed_flatten, 1) # bs, \sum{hxw}, c + spatial_shapes = torch.as_tensor( + spatial_shapes, dtype=torch.long, device=src_flatten.device + ) + level_start_index = torch.cat( + (spatial_shapes.new_zeros((1,)), spatial_shapes.prod(1).cumsum(0)[:-1]) + ) + valid_ratios = torch.stack([self.get_valid_ratio(m) for m in masks], 1) + + # two stage + enc_topk_proposals = enc_refpoint_embed = None + + ######################################################### + # Begin Encoder + ######################################################### + memory, memory_text = self.encoder( + src_flatten, + pos=lvl_pos_embed_flatten, + level_start_index=level_start_index, + spatial_shapes=spatial_shapes, + valid_ratios=valid_ratios, + key_padding_mask=mask_flatten, + memory_text=text_dict["encoded_text"], + text_attention_mask=~text_dict["text_token_mask"], + # we ~ the mask . False means use the token; True means pad the token + position_ids=text_dict["position_ids"], + text_self_attention_masks=text_dict["text_self_attention_masks"], + ) + ######################################################### + # End Encoder + # - memory: bs, \sum{hw}, c + # - mask_flatten: bs, \sum{hw} + # - lvl_pos_embed_flatten: bs, \sum{hw}, c + # - enc_intermediate_output: None or (nenc+1, bs, nq, c) or (nenc, bs, nq, c) + # - enc_intermediate_refpoints: None or (nenc+1, bs, nq, c) or (nenc, bs, nq, c) + ######################################################### + text_dict["encoded_text"] = memory_text + # if os.environ.get("SHILONG_AMP_INFNAN_DEBUG") == '1': + # if memory.isnan().any() | memory.isinf().any(): + # import ipdb; ipdb.set_trace() + + if self.two_stage_type == "standard": + output_memory, output_proposals = gen_encoder_output_proposals( + memory, mask_flatten, spatial_shapes + ) + output_memory = self.enc_output_norm(self.enc_output(output_memory)) + + if text_dict is not None: + enc_outputs_class_unselected = self.enc_out_class_embed(output_memory, text_dict) + else: + enc_outputs_class_unselected = self.enc_out_class_embed(output_memory) + + topk_logits = enc_outputs_class_unselected.max(-1)[0] + enc_outputs_coord_unselected = ( + self.enc_out_bbox_embed(output_memory) + output_proposals + ) # (bs, \sum{hw}, 4) unsigmoid + topk = self.num_queries + + topk_proposals = torch.topk(topk_logits, topk, dim=1)[1] # bs, nq + + # gather boxes + refpoint_embed_undetach = torch.gather( + enc_outputs_coord_unselected, 1, topk_proposals.unsqueeze(-1).repeat(1, 1, 4) + ) # unsigmoid + refpoint_embed_ = refpoint_embed_undetach.detach() + init_box_proposal = torch.gather( + output_proposals, 1, topk_proposals.unsqueeze(-1).repeat(1, 1, 4) + ).sigmoid() # sigmoid + + # gather tgt + tgt_undetach = torch.gather( + output_memory, 1, topk_proposals.unsqueeze(-1).repeat(1, 1, self.d_model) + ) + if self.embed_init_tgt: + tgt_ = ( + self.tgt_embed.weight[:, None, :].repeat(1, bs, 1).transpose(0, 1) + ) # nq, bs, d_model + else: + tgt_ = tgt_undetach.detach() + + if refpoint_embed is not None: + refpoint_embed = torch.cat([refpoint_embed, refpoint_embed_], dim=1) + tgt = torch.cat([tgt, tgt_], dim=1) + else: + refpoint_embed, tgt = refpoint_embed_, tgt_ + + elif self.two_stage_type == "no": + tgt_ = ( + self.tgt_embed.weight[:, None, :].repeat(1, bs, 1).transpose(0, 1) + ) # nq, bs, d_model + refpoint_embed_ = ( + self.refpoint_embed.weight[:, None, :].repeat(1, bs, 1).transpose(0, 1) + ) # nq, bs, 4 + + if refpoint_embed is not None: + refpoint_embed = torch.cat([refpoint_embed, refpoint_embed_], dim=1) + tgt = torch.cat([tgt, tgt_], dim=1) + else: + refpoint_embed, tgt = refpoint_embed_, tgt_ + + if self.num_patterns > 0: + tgt_embed = tgt.repeat(1, self.num_patterns, 1) + refpoint_embed = refpoint_embed.repeat(1, self.num_patterns, 1) + tgt_pat = self.patterns.weight[None, :, :].repeat_interleave( + self.num_queries, 1 + ) # 1, n_q*n_pat, d_model + tgt = tgt_embed + tgt_pat + + init_box_proposal = refpoint_embed_.sigmoid() + + else: + raise NotImplementedError("unknown two_stage_type {}".format(self.two_stage_type)) + ######################################################### + # End preparing tgt + # - tgt: bs, NQ, d_model + # - refpoint_embed(unsigmoid): bs, NQ, d_model + ######################################################### + + ######################################################### + # Begin Decoder + ######################################################### + hs, references = self.decoder( + tgt=tgt.transpose(0, 1), + memory=memory.transpose(0, 1), + memory_key_padding_mask=mask_flatten, + pos=lvl_pos_embed_flatten.transpose(0, 1), + refpoints_unsigmoid=refpoint_embed.transpose(0, 1), + level_start_index=level_start_index, + spatial_shapes=spatial_shapes, + valid_ratios=valid_ratios, + tgt_mask=attn_mask, + memory_text=text_dict["encoded_text"], + text_attention_mask=~text_dict["text_token_mask"], + # we ~ the mask . False means use the token; True means pad the token + ) + ######################################################### + # End Decoder + # hs: n_dec, bs, nq, d_model + # references: n_dec+1, bs, nq, query_dim + ######################################################### + + ######################################################### + # Begin postprocess + ######################################################### + if self.two_stage_type == "standard": + hs_enc = tgt_undetach.unsqueeze(0) + ref_enc = refpoint_embed_undetach.sigmoid().unsqueeze(0) + else: + hs_enc = ref_enc = None + ######################################################### + # End postprocess + # hs_enc: (n_enc+1, bs, nq, d_model) or (1, bs, nq, d_model) or (n_enc, bs, nq, d_model) or None + # ref_enc: (n_enc+1, bs, nq, query_dim) or (1, bs, nq, query_dim) or (n_enc, bs, nq, d_model) or None + ######################################################### + + return hs, references, hs_enc, ref_enc, init_box_proposal + # hs: (n_dec, bs, nq, d_model) + # references: sigmoid coordinates. (n_dec+1, bs, bq, 4) + # hs_enc: (n_enc+1, bs, nq, d_model) or (1, bs, nq, d_model) or None + # ref_enc: sigmoid coordinates. \ + # (n_enc+1, bs, nq, query_dim) or (1, bs, nq, query_dim) or None + + +class TransformerEncoder(nn.Module): + def __init__( + self, + encoder_layer, + num_layers, + d_model=256, + num_queries=300, + enc_layer_share=False, + text_enhance_layer=None, + feature_fusion_layer=None, + use_checkpoint=False, + use_transformer_ckpt=False, + ): + """_summary_ + + Args: + encoder_layer (_type_): _description_ + num_layers (_type_): _description_ + norm (_type_, optional): _description_. Defaults to None. + d_model (int, optional): _description_. Defaults to 256. + num_queries (int, optional): _description_. Defaults to 300. + enc_layer_share (bool, optional): _description_. Defaults to False. + + """ + super().__init__() + # prepare layers + self.layers = [] + self.text_layers = [] + self.fusion_layers = [] + if num_layers > 0: + self.layers = _get_clones(encoder_layer, num_layers, layer_share=enc_layer_share) + + if text_enhance_layer is not None: + self.text_layers = _get_clones( + text_enhance_layer, num_layers, layer_share=enc_layer_share + ) + if feature_fusion_layer is not None: + self.fusion_layers = _get_clones( + feature_fusion_layer, num_layers, layer_share=enc_layer_share + ) + else: + self.layers = [] + del encoder_layer + + if text_enhance_layer is not None: + self.text_layers = [] + del text_enhance_layer + if feature_fusion_layer is not None: + self.fusion_layers = [] + del feature_fusion_layer + + self.query_scale = None + self.num_queries = num_queries + self.num_layers = num_layers + self.d_model = d_model + + self.use_checkpoint = use_checkpoint + self.use_transformer_ckpt = use_transformer_ckpt + + @staticmethod + def get_reference_points(spatial_shapes, valid_ratios, device): + reference_points_list = [] + for lvl, (H_, W_) in enumerate(spatial_shapes): + + ref_y, ref_x = torch.meshgrid( + torch.linspace(0.5, H_ - 0.5, H_, dtype=torch.float32, device=device), + torch.linspace(0.5, W_ - 0.5, W_, dtype=torch.float32, device=device), + ) + ref_y = ref_y.reshape(-1)[None] / (valid_ratios[:, None, lvl, 1] * H_) + ref_x = ref_x.reshape(-1)[None] / (valid_ratios[:, None, lvl, 0] * W_) + ref = torch.stack((ref_x, ref_y), -1) + reference_points_list.append(ref) + reference_points = torch.cat(reference_points_list, 1) + reference_points = reference_points[:, :, None] * valid_ratios[:, None] + return reference_points + + def forward( + self, + # for images + src: Tensor, + pos: Tensor, + spatial_shapes: Tensor, + level_start_index: Tensor, + valid_ratios: Tensor, + key_padding_mask: Tensor, + # for texts + memory_text: Tensor = None, + text_attention_mask: Tensor = None, + pos_text: Tensor = None, + text_self_attention_masks: Tensor = None, + position_ids: Tensor = None, + ): + """ + Input: + - src: [bs, sum(hi*wi), 256] + - pos: pos embed for src. [bs, sum(hi*wi), 256] + - spatial_shapes: h,w of each level [num_level, 2] + - level_start_index: [num_level] start point of level in sum(hi*wi). + - valid_ratios: [bs, num_level, 2] + - key_padding_mask: [bs, sum(hi*wi)] + + - memory_text: bs, n_text, 256 + - text_attention_mask: bs, n_text + False for no padding; True for padding + - pos_text: bs, n_text, 256 + + - position_ids: bs, n_text + Intermedia: + - reference_points: [bs, sum(hi*wi), num_level, 2] + Outpus: + - output: [bs, sum(hi*wi), 256] + """ + + output = src + + # preparation and reshape + if self.num_layers > 0: + reference_points = self.get_reference_points( + spatial_shapes, valid_ratios, device=src.device + ) + + if self.text_layers: + # generate pos_text + bs, n_text, text_dim = memory_text.shape + if pos_text is None and position_ids is None: + pos_text = ( + torch.arange(n_text, device=memory_text.device) + .float() + .unsqueeze(0) + .unsqueeze(-1) + .repeat(bs, 1, 1) + ) + pos_text = get_sine_pos_embed(pos_text, num_pos_feats=256, exchange_xy=False) + if position_ids is not None: + pos_text = get_sine_pos_embed( + position_ids[..., None], num_pos_feats=256, exchange_xy=False + ) + + # main process + for layer_id, layer in enumerate(self.layers): + # if output.isnan().any() or memory_text.isnan().any(): + # if os.environ.get('IPDB_SHILONG_DEBUG', None) == 'INFO': + # import ipdb; ipdb.set_trace() + if self.fusion_layers: + if self.use_checkpoint: + output, memory_text = checkpoint.checkpoint( + self.fusion_layers[layer_id], + output, + memory_text, + key_padding_mask, + text_attention_mask, + ) + else: + output, memory_text = self.fusion_layers[layer_id]( + v=output, + l=memory_text, + attention_mask_v=key_padding_mask, + attention_mask_l=text_attention_mask, + ) + + if self.text_layers: + memory_text = self.text_layers[layer_id]( + src=memory_text.transpose(0, 1), + src_mask=~text_self_attention_masks, # note we use ~ for mask here + src_key_padding_mask=text_attention_mask, + pos=(pos_text.transpose(0, 1) if pos_text is not None else None), + ).transpose(0, 1) + + # main process + if self.use_transformer_ckpt: + output = checkpoint.checkpoint( + layer, + output, + pos, + reference_points, + spatial_shapes, + level_start_index, + key_padding_mask, + ) + else: + output = layer( + src=output, + pos=pos, + reference_points=reference_points, + spatial_shapes=spatial_shapes, + level_start_index=level_start_index, + key_padding_mask=key_padding_mask, + ) + + return output, memory_text + + +class TransformerDecoder(nn.Module): + def __init__( + self, + decoder_layer, + num_layers, + norm=None, + return_intermediate=False, + d_model=256, + query_dim=4, + num_feature_levels=1, + ): + super().__init__() + if num_layers > 0: + self.layers = _get_clones(decoder_layer, num_layers) + else: + self.layers = [] + self.num_layers = num_layers + self.norm = norm + self.return_intermediate = return_intermediate + assert return_intermediate, "support return_intermediate only" + self.query_dim = query_dim + assert query_dim in [2, 4], "query_dim should be 2/4 but {}".format(query_dim) + self.num_feature_levels = num_feature_levels + + self.ref_point_head = MLP(query_dim // 2 * d_model, d_model, d_model, 2) + self.query_pos_sine_scale = None + + self.query_scale = None + self.bbox_embed = None + self.class_embed = None + + self.d_model = d_model + + self.ref_anchor_head = None + + def forward( + self, + tgt, + memory, + tgt_mask: Optional[Tensor] = None, + memory_mask: Optional[Tensor] = None, + tgt_key_padding_mask: Optional[Tensor] = None, + memory_key_padding_mask: Optional[Tensor] = None, + pos: Optional[Tensor] = None, + refpoints_unsigmoid: Optional[Tensor] = None, # num_queries, bs, 2 + # for memory + level_start_index: Optional[Tensor] = None, # num_levels + spatial_shapes: Optional[Tensor] = None, # bs, num_levels, 2 + valid_ratios: Optional[Tensor] = None, + # for text + memory_text: Optional[Tensor] = None, + text_attention_mask: Optional[Tensor] = None, + ): + """ + Input: + - tgt: nq, bs, d_model + - memory: hw, bs, d_model + - pos: hw, bs, d_model + - refpoints_unsigmoid: nq, bs, 2/4 + - valid_ratios/spatial_shapes: bs, nlevel, 2 + """ + output = tgt + + intermediate = [] + reference_points = refpoints_unsigmoid.sigmoid() + ref_points = [reference_points] + + for layer_id, layer in enumerate(self.layers): + + if reference_points.shape[-1] == 4: + reference_points_input = ( + reference_points[:, :, None] + * torch.cat([valid_ratios, valid_ratios], -1)[None, :] + ) # nq, bs, nlevel, 4 + else: + assert reference_points.shape[-1] == 2 + reference_points_input = reference_points[:, :, None] * valid_ratios[None, :] + query_sine_embed = gen_sineembed_for_position( + reference_points_input[:, :, 0, :] + ) # nq, bs, 256*2 + + # conditional query + raw_query_pos = self.ref_point_head(query_sine_embed) # nq, bs, 256 + pos_scale = self.query_scale(output) if self.query_scale is not None else 1 + query_pos = pos_scale * raw_query_pos + # if os.environ.get("SHILONG_AMP_INFNAN_DEBUG") == '1': + # if query_pos.isnan().any() | query_pos.isinf().any(): + # import ipdb; ipdb.set_trace() + + # main process + output = layer( + tgt=output, + tgt_query_pos=query_pos, + tgt_query_sine_embed=query_sine_embed, + tgt_key_padding_mask=tgt_key_padding_mask, + tgt_reference_points=reference_points_input, + memory_text=memory_text, + text_attention_mask=text_attention_mask, + memory=memory, + memory_key_padding_mask=memory_key_padding_mask, + memory_level_start_index=level_start_index, + memory_spatial_shapes=spatial_shapes, + memory_pos=pos, + self_attn_mask=tgt_mask, + cross_attn_mask=memory_mask, + ) + if output.isnan().any() | output.isinf().any(): + print(f"output layer_id {layer_id} is nan") + try: + num_nan = output.isnan().sum().item() + num_inf = output.isinf().sum().item() + print(f"num_nan {num_nan}, num_inf {num_inf}") + except Exception as e: + print(e) + # if os.environ.get("SHILONG_AMP_INFNAN_DEBUG") == '1': + # import ipdb; ipdb.set_trace() + + # iter update + if self.bbox_embed is not None: + # box_holder = self.bbox_embed(output) + # box_holder[..., :self.query_dim] += inverse_sigmoid(reference_points) + # new_reference_points = box_holder[..., :self.query_dim].sigmoid() + + reference_before_sigmoid = inverse_sigmoid(reference_points) + delta_unsig = self.bbox_embed[layer_id](output) + outputs_unsig = delta_unsig + reference_before_sigmoid + new_reference_points = outputs_unsig.sigmoid() + + reference_points = new_reference_points.detach() + # if layer_id != self.num_layers - 1: + ref_points.append(new_reference_points) + + intermediate.append(self.norm(output)) + + return [ + [itm_out.transpose(0, 1) for itm_out in intermediate], + [itm_refpoint.transpose(0, 1) for itm_refpoint in ref_points], + ] + + +class DeformableTransformerEncoderLayer(nn.Module): + def __init__( + self, + d_model=256, + d_ffn=1024, + dropout=0.1, + activation="relu", + n_levels=4, + n_heads=8, + n_points=4, + ): + super().__init__() + + # self attention + self.self_attn = MSDeformAttn( + embed_dim=d_model, + num_levels=n_levels, + num_heads=n_heads, + num_points=n_points, + batch_first=True, + ) + self.dropout1 = nn.Dropout(dropout) + self.norm1 = nn.LayerNorm(d_model) + + # ffn + self.linear1 = nn.Linear(d_model, d_ffn) + self.activation = _get_activation_fn(activation, d_model=d_ffn) + self.dropout2 = nn.Dropout(dropout) + self.linear2 = nn.Linear(d_ffn, d_model) + self.dropout3 = nn.Dropout(dropout) + self.norm2 = nn.LayerNorm(d_model) + + @staticmethod + def with_pos_embed(tensor, pos): + return tensor if pos is None else tensor + pos + + def forward_ffn(self, src): + src2 = self.linear2(self.dropout2(self.activation(self.linear1(src)))) + src = src + self.dropout3(src2) + src = self.norm2(src) + return src + + def forward( + self, src, pos, reference_points, spatial_shapes, level_start_index, key_padding_mask=None + ): + # self attention + # import ipdb; ipdb.set_trace() + src2 = self.self_attn( + query=self.with_pos_embed(src, pos), + reference_points=reference_points, + value=src, + spatial_shapes=spatial_shapes, + level_start_index=level_start_index, + key_padding_mask=key_padding_mask, + ) + src = src + self.dropout1(src2) + src = self.norm1(src) + + # ffn + src = self.forward_ffn(src) + + return src + + +class DeformableTransformerDecoderLayer(nn.Module): + def __init__( + self, + d_model=256, + d_ffn=1024, + dropout=0.1, + activation="relu", + n_levels=4, + n_heads=8, + n_points=4, + use_text_feat_guide=False, + use_text_cross_attention=False, + ): + super().__init__() + + # cross attention + self.cross_attn = MSDeformAttn( + embed_dim=d_model, + num_levels=n_levels, + num_heads=n_heads, + num_points=n_points, + batch_first=True, + ) + self.dropout1 = nn.Dropout(dropout) if dropout > 0 else nn.Identity() + self.norm1 = nn.LayerNorm(d_model) + + # cross attention text + if use_text_cross_attention: + self.ca_text = nn.MultiheadAttention(d_model, n_heads, dropout=dropout) + self.catext_dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity() + self.catext_norm = nn.LayerNorm(d_model) + + # self attention + self.self_attn = nn.MultiheadAttention(d_model, n_heads, dropout=dropout) + self.dropout2 = nn.Dropout(dropout) if dropout > 0 else nn.Identity() + self.norm2 = nn.LayerNorm(d_model) + + # ffn + self.linear1 = nn.Linear(d_model, d_ffn) + self.activation = _get_activation_fn(activation, d_model=d_ffn, batch_dim=1) + self.dropout3 = nn.Dropout(dropout) if dropout > 0 else nn.Identity() + self.linear2 = nn.Linear(d_ffn, d_model) + self.dropout4 = nn.Dropout(dropout) if dropout > 0 else nn.Identity() + self.norm3 = nn.LayerNorm(d_model) + + self.key_aware_proj = None + self.use_text_feat_guide = use_text_feat_guide + assert not use_text_feat_guide + self.use_text_cross_attention = use_text_cross_attention + + def rm_self_attn_modules(self): + self.self_attn = None + self.dropout2 = None + self.norm2 = None + + @staticmethod + def with_pos_embed(tensor, pos): + return tensor if pos is None else tensor + pos + + def forward_ffn(self, tgt): + with torch.cuda.amp.autocast(enabled=False): + tgt2 = self.linear2(self.dropout3(self.activation(self.linear1(tgt)))) + tgt = tgt + self.dropout4(tgt2) + tgt = self.norm3(tgt) + return tgt + + def forward( + self, + # for tgt + tgt: Optional[Tensor], # nq, bs, d_model + tgt_query_pos: Optional[Tensor] = None, # pos for query. MLP(Sine(pos)) + tgt_query_sine_embed: Optional[Tensor] = None, # pos for query. Sine(pos) + tgt_key_padding_mask: Optional[Tensor] = None, + tgt_reference_points: Optional[Tensor] = None, # nq, bs, 4 + memory_text: Optional[Tensor] = None, # bs, num_token, d_model + text_attention_mask: Optional[Tensor] = None, # bs, num_token + # for memory + memory: Optional[Tensor] = None, # hw, bs, d_model + memory_key_padding_mask: Optional[Tensor] = None, + memory_level_start_index: Optional[Tensor] = None, # num_levels + memory_spatial_shapes: Optional[Tensor] = None, # bs, num_levels, 2 + memory_pos: Optional[Tensor] = None, # pos for memory + # sa + self_attn_mask: Optional[Tensor] = None, # mask used for self-attention + cross_attn_mask: Optional[Tensor] = None, # mask used for cross-attention + ): + """ + Input: + - tgt/tgt_query_pos: nq, bs, d_model + - + """ + assert cross_attn_mask is None + + # self attention + if self.self_attn is not None: + # import ipdb; ipdb.set_trace() + q = k = self.with_pos_embed(tgt, tgt_query_pos) + tgt2 = self.self_attn(q, k, tgt, attn_mask=self_attn_mask)[0] + tgt = tgt + self.dropout2(tgt2) + tgt = self.norm2(tgt) + + if self.use_text_cross_attention: + tgt2 = self.ca_text( + self.with_pos_embed(tgt, tgt_query_pos), + memory_text.transpose(0, 1), + memory_text.transpose(0, 1), + key_padding_mask=text_attention_mask, + )[0] + tgt = tgt + self.catext_dropout(tgt2) + tgt = self.catext_norm(tgt) + + tgt2 = self.cross_attn( + query=self.with_pos_embed(tgt, tgt_query_pos).transpose(0, 1), + reference_points=tgt_reference_points.transpose(0, 1).contiguous(), + value=memory.transpose(0, 1), + spatial_shapes=memory_spatial_shapes, + level_start_index=memory_level_start_index, + key_padding_mask=memory_key_padding_mask, + ).transpose(0, 1) + tgt = tgt + self.dropout1(tgt2) + tgt = self.norm1(tgt) + + # ffn + tgt = self.forward_ffn(tgt) + + return tgt + + +def build_transformer(args): + return Transformer( + d_model=args.hidden_dim, + dropout=args.dropout, + nhead=args.nheads, + num_queries=args.num_queries, + dim_feedforward=args.dim_feedforward, + num_encoder_layers=args.enc_layers, + num_decoder_layers=args.dec_layers, + normalize_before=args.pre_norm, + return_intermediate_dec=True, + query_dim=args.query_dim, + activation=args.transformer_activation, + num_patterns=args.num_patterns, + num_feature_levels=args.num_feature_levels, + enc_n_points=args.enc_n_points, + dec_n_points=args.dec_n_points, + learnable_tgt_init=True, + # two stage + two_stage_type=args.two_stage_type, # ['no', 'standard', 'early'] + embed_init_tgt=args.embed_init_tgt, + use_text_enhancer=args.use_text_enhancer, + use_fusion_layer=args.use_fusion_layer, + use_checkpoint=args.use_checkpoint, + use_transformer_ckpt=args.use_transformer_ckpt, + use_text_cross_attention=args.use_text_cross_attention, + text_dropout=args.text_dropout, + fusion_dropout=args.fusion_dropout, + fusion_droppath=args.fusion_droppath, + ) + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer_vanilla.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer_vanilla.py new file mode 100644 index 0000000..78f67e5 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer_vanilla.py @@ -0,0 +1,119 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Copyright (c) Aishwarya Kamath & Nicolas Carion. Licensed under the Apache License 2.0. All Rights Reserved +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved +""" +DETR Transformer class. + +Copy-paste from torch.nn.Transformer with modifications: + * positional encodings are passed in MHattention + * extra LN at the end of encoder is removed + * decoder returns a stack of activations from all decoding layers +""" +from typing import Optional + +import torch +from torch import Tensor, nn + +from .utils import ( + _get_activation_fn, + _get_clones, +) + + +class TextTransformer(nn.Module): + def __init__(self, num_layers, d_model=256, nheads=8, dim_feedforward=2048, dropout=0.1): + super().__init__() + self.num_layers = num_layers + self.d_model = d_model + self.nheads = nheads + self.dim_feedforward = dim_feedforward + self.norm = None + + single_encoder_layer = TransformerEncoderLayer( + d_model=d_model, nhead=nheads, dim_feedforward=dim_feedforward, dropout=dropout + ) + self.layers = _get_clones(single_encoder_layer, num_layers) + + def forward(self, memory_text: torch.Tensor, text_attention_mask: torch.Tensor): + """ + + Args: + text_attention_mask: bs, num_token + memory_text: bs, num_token, d_model + + Raises: + RuntimeError: _description_ + + Returns: + output: bs, num_token, d_model + """ + + output = memory_text.transpose(0, 1) + + for layer in self.layers: + output = layer(output, src_key_padding_mask=text_attention_mask) + + if self.norm is not None: + output = self.norm(output) + + return output.transpose(0, 1) + + +class TransformerEncoderLayer(nn.Module): + def __init__( + self, + d_model, + nhead, + dim_feedforward=2048, + dropout=0.1, + activation="relu", + normalize_before=False, + ): + super().__init__() + self.self_attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout) + # Implementation of Feedforward model + self.linear1 = nn.Linear(d_model, dim_feedforward) + self.dropout = nn.Dropout(dropout) + self.linear2 = nn.Linear(dim_feedforward, d_model) + + self.norm1 = nn.LayerNorm(d_model) + self.norm2 = nn.LayerNorm(d_model) + self.dropout1 = nn.Dropout(dropout) + self.dropout2 = nn.Dropout(dropout) + + self.activation = _get_activation_fn(activation) + self.normalize_before = normalize_before + self.nhead = nhead + + def with_pos_embed(self, tensor, pos: Optional[Tensor]): + return tensor if pos is None else tensor + pos + + def forward( + self, + src, + src_mask: Optional[Tensor] = None, + src_key_padding_mask: Optional[Tensor] = None, + pos: Optional[Tensor] = None, + ): + # repeat attn mask + if src_mask.dim() == 3 and src_mask.shape[0] == src.shape[1]: + # bs, num_q, num_k + src_mask = src_mask.repeat(self.nhead, 1, 1) + + q = k = self.with_pos_embed(src, pos) + + src2 = self.self_attn(q, k, value=src, attn_mask=src_mask)[0] + + # src2 = self.self_attn(q, k, value=src, attn_mask=src_mask, key_padding_mask=src_key_padding_mask)[0] + src = src + self.dropout1(src2) + src = self.norm1(src) + src2 = self.linear2(self.dropout(self.activation(self.linear1(src)))) + src = src + self.dropout2(src2) + src = self.norm2(src) + return src + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/utils.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/utils.py new file mode 100644 index 0000000..e79179c --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/utils.py @@ -0,0 +1,269 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +import copy +import math + +import torch +import torch.nn.functional as F +from torch import Tensor, nn + + +def _get_clones(module, N, layer_share=False): + # import ipdb; ipdb.set_trace() + if layer_share: + return nn.ModuleList([module for i in range(N)]) + else: + return nn.ModuleList([copy.deepcopy(module) for i in range(N)]) + + +def get_sine_pos_embed( + pos_tensor: torch.Tensor, + num_pos_feats: int = 128, + temperature: int = 10000, + exchange_xy: bool = True, +): + """generate sine position embedding from a position tensor + Args: + pos_tensor (torch.Tensor): shape: [..., n]. + num_pos_feats (int): projected shape for each float in the tensor. + temperature (int): temperature in the sine/cosine function. + exchange_xy (bool, optional): exchange pos x and pos y. \ + For example, input tensor is [x,y], the results will be [pos(y), pos(x)]. Defaults to True. + Returns: + pos_embed (torch.Tensor): shape: [..., n*num_pos_feats]. + """ + scale = 2 * math.pi + dim_t = torch.arange(num_pos_feats, dtype=torch.float32, device=pos_tensor.device) + dim_t = temperature ** (2 * torch.div(dim_t, 2, rounding_mode="floor") / num_pos_feats) + + def sine_func(x: torch.Tensor): + sin_x = x * scale / dim_t + sin_x = torch.stack((sin_x[..., 0::2].sin(), sin_x[..., 1::2].cos()), dim=3).flatten(2) + return sin_x + + pos_res = [sine_func(x) for x in pos_tensor.split([1] * pos_tensor.shape[-1], dim=-1)] + if exchange_xy: + pos_res[0], pos_res[1] = pos_res[1], pos_res[0] + pos_res = torch.cat(pos_res, dim=-1) + return pos_res + + +def gen_encoder_output_proposals( + memory: Tensor, memory_padding_mask: Tensor, spatial_shapes: Tensor, learnedwh=None +): + """ + Input: + - memory: bs, \sum{hw}, d_model + - memory_padding_mask: bs, \sum{hw} + - spatial_shapes: nlevel, 2 + - learnedwh: 2 + Output: + - output_memory: bs, \sum{hw}, d_model + - output_proposals: bs, \sum{hw}, 4 + """ + N_, S_, C_ = memory.shape + proposals = [] + _cur = 0 + for lvl, (H_, W_) in enumerate(spatial_shapes): + mask_flatten_ = memory_padding_mask[:, _cur : (_cur + H_ * W_)].view(N_, H_, W_, 1) + valid_H = torch.sum(~mask_flatten_[:, :, 0, 0], 1) + valid_W = torch.sum(~mask_flatten_[:, 0, :, 0], 1) + + # import ipdb; ipdb.set_trace() + + grid_y, grid_x = torch.meshgrid( + torch.linspace(0, H_ - 1, H_, dtype=torch.float32, device=memory.device), + torch.linspace(0, W_ - 1, W_, dtype=torch.float32, device=memory.device), + ) + grid = torch.cat([grid_x.unsqueeze(-1), grid_y.unsqueeze(-1)], -1) # H_, W_, 2 + + scale = torch.cat([valid_W.unsqueeze(-1), valid_H.unsqueeze(-1)], 1).view(N_, 1, 1, 2) + grid = (grid.unsqueeze(0).expand(N_, -1, -1, -1) + 0.5) / scale + + if learnedwh is not None: + # import ipdb; ipdb.set_trace() + wh = torch.ones_like(grid) * learnedwh.sigmoid() * (2.0**lvl) + else: + wh = torch.ones_like(grid) * 0.05 * (2.0**lvl) + + # scale = torch.cat([W_[None].unsqueeze(-1), H_[None].unsqueeze(-1)], 1).view(1, 1, 1, 2).repeat(N_, 1, 1, 1) + # grid = (grid.unsqueeze(0).expand(N_, -1, -1, -1) + 0.5) / scale + # wh = torch.ones_like(grid) / scale + proposal = torch.cat((grid, wh), -1).view(N_, -1, 4) + proposals.append(proposal) + _cur += H_ * W_ + # import ipdb; ipdb.set_trace() + output_proposals = torch.cat(proposals, 1) + output_proposals_valid = ((output_proposals > 0.01) & (output_proposals < 0.99)).all( + -1, keepdim=True + ) + output_proposals = torch.log(output_proposals / (1 - output_proposals)) # unsigmoid + output_proposals = output_proposals.masked_fill(memory_padding_mask.unsqueeze(-1), float("inf")) + output_proposals = output_proposals.masked_fill(~output_proposals_valid, float("inf")) + + output_memory = memory + output_memory = output_memory.masked_fill(memory_padding_mask.unsqueeze(-1), float(0)) + output_memory = output_memory.masked_fill(~output_proposals_valid, float(0)) + + # output_memory = output_memory.masked_fill(memory_padding_mask.unsqueeze(-1), float('inf')) + # output_memory = output_memory.masked_fill(~output_proposals_valid, float('inf')) + + return output_memory, output_proposals + + +class RandomBoxPerturber: + def __init__( + self, x_noise_scale=0.2, y_noise_scale=0.2, w_noise_scale=0.2, h_noise_scale=0.2 + ) -> None: + self.noise_scale = torch.Tensor( + [x_noise_scale, y_noise_scale, w_noise_scale, h_noise_scale] + ) + + def __call__(self, refanchors: Tensor) -> Tensor: + nq, bs, query_dim = refanchors.shape + device = refanchors.device + + noise_raw = torch.rand_like(refanchors) + noise_scale = self.noise_scale.to(device)[:query_dim] + + new_refanchors = refanchors * (1 + (noise_raw - 0.5) * noise_scale) + return new_refanchors.clamp_(0, 1) + + +def sigmoid_focal_loss( + inputs, targets, num_boxes, alpha: float = 0.25, gamma: float = 2, no_reduction=False +): + """ + Loss used in RetinaNet for dense detection: https://arxiv.org/abs/1708.02002. + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + alpha: (optional) Weighting factor in range (0,1) to balance + positive vs negative examples. Default = -1 (no weighting). + gamma: Exponent of the modulating factor (1 - p_t) to + balance easy vs hard examples. + Returns: + Loss tensor + """ + prob = inputs.sigmoid() + ce_loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction="none") + p_t = prob * targets + (1 - prob) * (1 - targets) + loss = ce_loss * ((1 - p_t) ** gamma) + + if alpha >= 0: + alpha_t = alpha * targets + (1 - alpha) * (1 - targets) + loss = alpha_t * loss + + if no_reduction: + return loss + + return loss.mean(1).sum() / num_boxes + + +class MLP(nn.Module): + """Very simple multi-layer perceptron (also called FFN)""" + + def __init__(self, input_dim, hidden_dim, output_dim, num_layers): + super().__init__() + self.num_layers = num_layers + h = [hidden_dim] * (num_layers - 1) + self.layers = nn.ModuleList( + nn.Linear(n, k) for n, k in zip([input_dim] + h, h + [output_dim]) + ) + + def forward(self, x): + for i, layer in enumerate(self.layers): + x = F.relu(layer(x)) if i < self.num_layers - 1 else layer(x) + return x + + +def _get_activation_fn(activation, d_model=256, batch_dim=0): + """Return an activation function given a string""" + if activation == "relu": + return F.relu + if activation == "gelu": + return F.gelu + if activation == "glu": + return F.glu + if activation == "prelu": + return nn.PReLU() + if activation == "selu": + return F.selu + + raise RuntimeError(f"activation should be relu/gelu, not {activation}.") + + +def gen_sineembed_for_position(pos_tensor): + # n_query, bs, _ = pos_tensor.size() + # sineembed_tensor = torch.zeros(n_query, bs, 256) + scale = 2 * math.pi + dim_t = torch.arange(128, dtype=torch.float32, device=pos_tensor.device) + dim_t = 10000 ** (2 * (torch.div(dim_t, 2, rounding_mode='floor')) / 128) + x_embed = pos_tensor[:, :, 0] * scale + y_embed = pos_tensor[:, :, 1] * scale + pos_x = x_embed[:, :, None] / dim_t + pos_y = y_embed[:, :, None] / dim_t + pos_x = torch.stack((pos_x[:, :, 0::2].sin(), pos_x[:, :, 1::2].cos()), dim=3).flatten(2) + pos_y = torch.stack((pos_y[:, :, 0::2].sin(), pos_y[:, :, 1::2].cos()), dim=3).flatten(2) + if pos_tensor.size(-1) == 2: + pos = torch.cat((pos_y, pos_x), dim=2) + elif pos_tensor.size(-1) == 4: + w_embed = pos_tensor[:, :, 2] * scale + pos_w = w_embed[:, :, None] / dim_t + pos_w = torch.stack((pos_w[:, :, 0::2].sin(), pos_w[:, :, 1::2].cos()), dim=3).flatten(2) + + h_embed = pos_tensor[:, :, 3] * scale + pos_h = h_embed[:, :, None] / dim_t + pos_h = torch.stack((pos_h[:, :, 0::2].sin(), pos_h[:, :, 1::2].cos()), dim=3).flatten(2) + + pos = torch.cat((pos_y, pos_x, pos_w, pos_h), dim=2) + else: + raise ValueError("Unknown pos_tensor shape(-1):{}".format(pos_tensor.size(-1))) + return pos + + +class ContrastiveEmbed(nn.Module): + def __init__(self, max_text_len=256): + """ + Args: + max_text_len: max length of text. + """ + super().__init__() + self.max_text_len = max_text_len + + def forward(self, x, text_dict): + """_summary_ + + Args: + x (_type_): _description_ + text_dict (_type_): _description_ + { + 'encoded_text': encoded_text, # bs, 195, d_model + 'text_token_mask': text_token_mask, # bs, 195 + # True for used tokens. False for padding tokens + } + Returns: + _type_: _description_ + """ + assert isinstance(text_dict, dict) + + y = text_dict["encoded_text"] + text_token_mask = text_dict["text_token_mask"] + + res = x @ y.transpose(-1, -2) + res.masked_fill_(~text_token_mask[:, None, :], float("-inf")) + + # padding to max_text_len + new_res = torch.full((*res.shape[:-1], self.max_text_len), float("-inf"), device=res.device) + new_res[..., : res.shape[-1]] = res + + return new_res + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/__init__.py b/simple_syrup/third_party/groundingdino_runtime/models/__init__.py new file mode 100644 index 0000000..c244ba8 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/__init__.py @@ -0,0 +1,19 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved +from .GroundingDINO import build_groundingdino + + +def build_model(args): + # we use register to maintain models from catdet6 on. + from .registry import MODULE_BUILD_FUNCS + + assert args.modelname in MODULE_BUILD_FUNCS._module_dict + build_func = MODULE_BUILD_FUNCS.get(args.modelname) + model = build_func(args) + return model + diff --git a/simple_syrup/third_party/groundingdino_runtime/models/registry.py b/simple_syrup/third_party/groundingdino_runtime/models/registry.py new file mode 100644 index 0000000..18e59d7 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/registry.py @@ -0,0 +1,67 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +# -*- coding: utf-8 -*- +# @Author: Yihao Chen +# @Date: 2021-08-16 16:03:17 +# @Last Modified by: Shilong Liu +# @Last Modified time: 2022-01-23 15:26 +# modified from mmcv + +import inspect +from functools import partial + + +class Registry(object): + def __init__(self, name): + self._name = name + self._module_dict = dict() + + def __repr__(self): + format_str = self.__class__.__name__ + "(name={}, items={})".format( + self._name, list(self._module_dict.keys()) + ) + return format_str + + def __len__(self): + return len(self._module_dict) + + @property + def name(self): + return self._name + + @property + def module_dict(self): + return self._module_dict + + def get(self, key): + return self._module_dict.get(key, None) + + def registe_with_name(self, module_name=None, force=False): + return partial(self.register, module_name=module_name, force=force) + + def register(self, module_build_function, module_name=None, force=False): + """Register a module build function. + Args: + module (:obj:`nn.Module`): Module to be registered. + """ + if not inspect.isfunction(module_build_function): + raise TypeError( + "module_build_function must be a function, but got {}".format( + type(module_build_function) + ) + ) + if module_name is None: + module_name = module_build_function.__name__ + if not force and module_name in self._module_dict: + raise KeyError("{} is already registered in {}".format(module_name, self.name)) + self._module_dict[module_name] = module_build_function + + return module_build_function + + +MODULE_BUILD_FUNCS = Registry("model build functions") + diff --git a/simple_syrup/third_party/groundingdino_runtime/util/__init__.py b/simple_syrup/third_party/groundingdino_runtime/util/__init__.py new file mode 100644 index 0000000..b10ecc6 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/util/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved + diff --git a/simple_syrup/third_party/groundingdino_runtime/util/box_ops.py b/simple_syrup/third_party/groundingdino_runtime/util/box_ops.py new file mode 100644 index 0000000..da1100a --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/util/box_ops.py @@ -0,0 +1,141 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved +""" +Utilities for bounding box manipulation and GIoU. +""" +import torch +from torchvision.ops.boxes import box_area + + +def box_cxcywh_to_xyxy(x): + x_c, y_c, w, h = x.unbind(-1) + b = [(x_c - 0.5 * w), (y_c - 0.5 * h), (x_c + 0.5 * w), (y_c + 0.5 * h)] + return torch.stack(b, dim=-1) + + +def box_xyxy_to_cxcywh(x): + x0, y0, x1, y1 = x.unbind(-1) + b = [(x0 + x1) / 2, (y0 + y1) / 2, (x1 - x0), (y1 - y0)] + return torch.stack(b, dim=-1) + + +# modified from torchvision to also return the union +def box_iou(boxes1, boxes2): + area1 = box_area(boxes1) + area2 = box_area(boxes2) + + # import ipdb; ipdb.set_trace() + lt = torch.max(boxes1[:, None, :2], boxes2[:, :2]) # [N,M,2] + rb = torch.min(boxes1[:, None, 2:], boxes2[:, 2:]) # [N,M,2] + + wh = (rb - lt).clamp(min=0) # [N,M,2] + inter = wh[:, :, 0] * wh[:, :, 1] # [N,M] + + union = area1[:, None] + area2 - inter + + iou = inter / (union + 1e-6) + return iou, union + + +def generalized_box_iou(boxes1, boxes2): + """ + Generalized IoU from https://giou.stanford.edu/ + + The boxes should be in [x0, y0, x1, y1] format + + Returns a [N, M] pairwise matrix, where N = len(boxes1) + and M = len(boxes2) + """ + # degenerate boxes gives inf / nan results + # so do an early check + assert (boxes1[:, 2:] >= boxes1[:, :2]).all() + assert (boxes2[:, 2:] >= boxes2[:, :2]).all() + # except: + # import ipdb; ipdb.set_trace() + iou, union = box_iou(boxes1, boxes2) + + lt = torch.min(boxes1[:, None, :2], boxes2[:, :2]) + rb = torch.max(boxes1[:, None, 2:], boxes2[:, 2:]) + + wh = (rb - lt).clamp(min=0) # [N,M,2] + area = wh[:, :, 0] * wh[:, :, 1] + + return iou - (area - union) / (area + 1e-6) + + +# modified from torchvision to also return the union +def box_iou_pairwise(boxes1, boxes2): + area1 = box_area(boxes1) + area2 = box_area(boxes2) + + lt = torch.max(boxes1[:, :2], boxes2[:, :2]) # [N,2] + rb = torch.min(boxes1[:, 2:], boxes2[:, 2:]) # [N,2] + + wh = (rb - lt).clamp(min=0) # [N,2] + inter = wh[:, 0] * wh[:, 1] # [N] + + union = area1 + area2 - inter + + iou = inter / union + return iou, union + + +def generalized_box_iou_pairwise(boxes1, boxes2): + """ + Generalized IoU from https://giou.stanford.edu/ + + Input: + - boxes1, boxes2: N,4 + Output: + - giou: N, 4 + """ + # degenerate boxes gives inf / nan results + # so do an early check + assert (boxes1[:, 2:] >= boxes1[:, :2]).all() + assert (boxes2[:, 2:] >= boxes2[:, :2]).all() + assert boxes1.shape == boxes2.shape + iou, union = box_iou_pairwise(boxes1, boxes2) # N, 4 + + lt = torch.min(boxes1[:, :2], boxes2[:, :2]) + rb = torch.max(boxes1[:, 2:], boxes2[:, 2:]) + + wh = (rb - lt).clamp(min=0) # [N,2] + area = wh[:, 0] * wh[:, 1] + + return iou - (area - union) / area + + +def masks_to_boxes(masks): + """Compute the bounding boxes around the provided masks + + The masks should be in format [N, H, W] where N is the number of masks, (H, W) are the spatial dimensions. + + Returns a [N, 4] tensors, with the boxes in xyxy format + """ + if masks.numel() == 0: + return torch.zeros((0, 4), device=masks.device) + + h, w = masks.shape[-2:] + + y = torch.arange(0, h, dtype=torch.float) + x = torch.arange(0, w, dtype=torch.float) + y, x = torch.meshgrid(y, x) + + x_mask = masks * x.unsqueeze(0) + x_max = x_mask.flatten(1).max(-1)[0] + x_min = x_mask.masked_fill(~(masks.bool()), 1e8).flatten(1).min(-1)[0] + + y_mask = masks * y.unsqueeze(0) + y_max = y_mask.flatten(1).max(-1)[0] + y_min = y_mask.masked_fill(~(masks.bool()), 1e8).flatten(1).min(-1)[0] + + return torch.stack([x_min, y_min, x_max, y_max], 1) + + +if __name__ == "__main__": + x = torch.rand(5, 4) + y = torch.rand(3, 4) + iou, union = box_iou(x, y) + import ipdb + + ipdb.set_trace() + diff --git a/simple_syrup/third_party/groundingdino_runtime/util/get_tokenlizer.py b/simple_syrup/third_party/groundingdino_runtime/util/get_tokenlizer.py new file mode 100644 index 0000000..a97f278 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/util/get_tokenlizer.py @@ -0,0 +1,30 @@ +from transformers import AutoTokenizer, BertModel, BertTokenizer, RobertaModel, RobertaTokenizerFast +import os + +def get_tokenlizer(text_encoder_type): + if not isinstance(text_encoder_type, str): + # print("text_encoder_type is not a str") + if hasattr(text_encoder_type, "text_encoder_type"): + text_encoder_type = text_encoder_type.text_encoder_type + elif text_encoder_type.get("text_encoder_type", False): + text_encoder_type = text_encoder_type.get("text_encoder_type") + elif os.path.isdir(text_encoder_type) and os.path.exists(text_encoder_type): + pass + else: + raise ValueError( + "Unknown type of text_encoder_type: {}".format(type(text_encoder_type)) + ) + print("final text_encoder_type: {}".format(text_encoder_type)) + + tokenizer = AutoTokenizer.from_pretrained(text_encoder_type) + return tokenizer + + +def get_pretrained_language_model(text_encoder_type): + if text_encoder_type == "bert-base-uncased" or (os.path.isdir(text_encoder_type) and os.path.exists(text_encoder_type)): + return BertModel.from_pretrained(text_encoder_type) + if text_encoder_type == "roberta-base": + return RobertaModel.from_pretrained(text_encoder_type) + + raise ValueError("Unknown text_encoder_type {}".format(text_encoder_type)) + diff --git a/simple_syrup/third_party/groundingdino_runtime/util/inference.py b/simple_syrup/third_party/groundingdino_runtime/util/inference.py new file mode 100644 index 0000000..96d8b9b --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/util/inference.py @@ -0,0 +1,245 @@ +from typing import Tuple, List + +import cv2 +import numpy as np +import supervision as sv +import torch +from PIL import Image +from torchvision.ops import box_convert + +import simple_syrup.third_party.groundingdino_runtime.datasets.transforms as T +from simple_syrup.third_party.groundingdino_runtime.models import build_model +from simple_syrup.third_party.groundingdino_runtime.util.misc import clean_state_dict +from simple_syrup.third_party.groundingdino_runtime.util.slconfig import SLConfig +from simple_syrup.third_party.groundingdino_runtime.util.utils import get_phrases_from_posmap + +# ---------------------------------------------------------------------------------------------------------------------- +# OLD API +# ---------------------------------------------------------------------------------------------------------------------- + + +def preprocess_caption(caption: str) -> str: + result = caption.lower().strip() + if result.endswith("."): + return result + return result + "." + + +def load_model(model_config_path: str, model_checkpoint_path: str, device: str = "cuda"): + args = SLConfig.fromfile(model_config_path) + args.device = device + model = build_model(args) + checkpoint = torch.load(model_checkpoint_path, map_location="cpu") + model.load_state_dict(clean_state_dict(checkpoint["model"]), strict=False) + model.eval() + return model + + +def load_image(image_path: str) -> Tuple[np.array, torch.Tensor]: + transform = T.Compose( + [ + T.RandomResize([800], max_size=1333), + T.ToTensor(), + T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), + ] + ) + image_source = Image.open(image_path).convert("RGB") + image = np.asarray(image_source) + image_transformed, _ = transform(image_source, None) + return image, image_transformed + + +def predict( + model, + image: torch.Tensor, + caption: str, + box_threshold: float, + text_threshold: float, + device: str = "cuda" +) -> Tuple[torch.Tensor, torch.Tensor, List[str]]: + caption = preprocess_caption(caption=caption) + + model = model.to(device) + image = image.to(device) + + with torch.no_grad(): + outputs = model(image[None], captions=[caption]) + + prediction_logits = outputs["pred_logits"].cpu().sigmoid()[0] # prediction_logits.shape = (nq, 256) + prediction_boxes = outputs["pred_boxes"].cpu()[0] # prediction_boxes.shape = (nq, 4) + + mask = prediction_logits.max(dim=1)[0] > box_threshold + logits = prediction_logits[mask] # logits.shape = (n, 256) + boxes = prediction_boxes[mask] # boxes.shape = (n, 4) + + tokenizer = model.tokenizer + tokenized = tokenizer(caption) + + phrases = [ + get_phrases_from_posmap(logit > text_threshold, tokenized, tokenizer).replace('.', '') + for logit + in logits + ] + + return boxes, logits.max(dim=1)[0], phrases + + +def annotate(image_source: np.ndarray, boxes: torch.Tensor, logits: torch.Tensor, phrases: List[str]) -> np.ndarray: + h, w, _ = image_source.shape + boxes = boxes * torch.Tensor([w, h, w, h]) + xyxy = box_convert(boxes=boxes, in_fmt="cxcywh", out_fmt="xyxy").numpy() + detections = sv.Detections(xyxy=xyxy) + + labels = [ + f"{phrase} {logit:.2f}" + for phrase, logit + in zip(phrases, logits) + ] + + box_annotator = sv.BoxAnnotator() + annotated_frame = cv2.cvtColor(image_source, cv2.COLOR_RGB2BGR) + annotated_frame = box_annotator.annotate(scene=annotated_frame, detections=detections, labels=labels) + return annotated_frame + + +# ---------------------------------------------------------------------------------------------------------------------- +# NEW API +# ---------------------------------------------------------------------------------------------------------------------- + + +class Model: + + def __init__( + self, + model_config_path: str, + model_checkpoint_path: str, + device: str = "cuda" + ): + self.model = load_model( + model_config_path=model_config_path, + model_checkpoint_path=model_checkpoint_path, + device=device + ).to(device) + self.device = device + + def predict_with_caption( + self, + image: np.ndarray, + caption: str, + box_threshold: float = 0.35, + text_threshold: float = 0.25 + ) -> Tuple[sv.Detections, List[str]]: + """ + import cv2 + + image = cv2.imread(IMAGE_PATH) + + model = Model(model_config_path=CONFIG_PATH, model_checkpoint_path=WEIGHTS_PATH) + detections, labels = model.predict_with_caption( + image=image, + caption=caption, + box_threshold=BOX_THRESHOLD, + text_threshold=TEXT_THRESHOLD + ) + + import supervision as sv + + box_annotator = sv.BoxAnnotator() + annotated_image = box_annotator.annotate(scene=image, detections=detections, labels=labels) + """ + processed_image = Model.preprocess_image(image_bgr=image).to(self.device) + boxes, logits, phrases = predict( + model=self.model, + image=processed_image, + caption=caption, + box_threshold=box_threshold, + text_threshold=text_threshold, + device=self.device) + source_h, source_w, _ = image.shape + detections = Model.post_process_result( + source_h=source_h, + source_w=source_w, + boxes=boxes, + logits=logits) + return detections, phrases + + def predict_with_classes( + self, + image: np.ndarray, + classes: List[str], + box_threshold: float, + text_threshold: float + ) -> sv.Detections: + """ + import cv2 + + image = cv2.imread(IMAGE_PATH) + + model = Model(model_config_path=CONFIG_PATH, model_checkpoint_path=WEIGHTS_PATH) + detections = model.predict_with_classes( + image=image, + classes=CLASSES, + box_threshold=BOX_THRESHOLD, + text_threshold=TEXT_THRESHOLD + ) + + + import supervision as sv + + box_annotator = sv.BoxAnnotator() + annotated_image = box_annotator.annotate(scene=image, detections=detections) + """ + caption = ". ".join(classes) + processed_image = Model.preprocess_image(image_bgr=image).to(self.device) + boxes, logits, phrases = predict( + model=self.model, + image=processed_image, + caption=caption, + box_threshold=box_threshold, + text_threshold=text_threshold, + device=self.device) + source_h, source_w, _ = image.shape + detections = Model.post_process_result( + source_h=source_h, + source_w=source_w, + boxes=boxes, + logits=logits) + class_id = Model.phrases2classes(phrases=phrases, classes=classes) + detections.class_id = class_id + return detections + + @staticmethod + def preprocess_image(image_bgr: np.ndarray) -> torch.Tensor: + transform = T.Compose( + [ + T.RandomResize([800], max_size=1333), + T.ToTensor(), + T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), + ] + ) + image_pillow = Image.fromarray(cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)) + image_transformed, _ = transform(image_pillow, None) + return image_transformed + + @staticmethod + def post_process_result( + source_h: int, + source_w: int, + boxes: torch.Tensor, + logits: torch.Tensor + ) -> sv.Detections: + boxes = boxes * torch.Tensor([source_w, source_h, source_w, source_h]) + xyxy = box_convert(boxes=boxes, in_fmt="cxcywh", out_fmt="xyxy").numpy() + confidence = logits.numpy() + return sv.Detections(xyxy=xyxy, confidence=confidence) + + @staticmethod + def phrases2classes(phrases: List[str], classes: List[str]) -> np.ndarray: + class_ids = [] + for phrase in phrases: + try: + class_ids.append(classes.index(phrase)) + except ValueError: + class_ids.append(None) + return np.array(class_ids) + diff --git a/simple_syrup/third_party/groundingdino_runtime/util/misc.py b/simple_syrup/third_party/groundingdino_runtime/util/misc.py new file mode 100644 index 0000000..d247766 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/util/misc.py @@ -0,0 +1,718 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved +""" +Misc functions, including distributed helpers. + +Mostly copy-paste from torchvision references. +""" +import colorsys +import datetime +import functools +import io +import json +import os +import pickle +import subprocess +import time +from collections import OrderedDict, defaultdict, deque +from typing import List, Optional + +import numpy as np +import torch +import torch.distributed as dist + +# needed due to empty tensor bug in pytorch and torchvision 0.5 +import torchvision +from torch import Tensor + +__torchvision_need_compat_flag = float(torchvision.__version__.split(".")[1]) < 7 +if __torchvision_need_compat_flag: + from torchvision.ops import _new_empty_tensor + from torchvision.ops.misc import _output_size + + +class SmoothedValue(object): + """Track a series of values and provide access to smoothed values over a + window or the global series average. + """ + + def __init__(self, window_size=20, fmt=None): + if fmt is None: + fmt = "{median:.4f} ({global_avg:.4f})" + self.deque = deque(maxlen=window_size) + self.total = 0.0 + self.count = 0 + self.fmt = fmt + + def update(self, value, n=1): + self.deque.append(value) + self.count += n + self.total += value * n + + def synchronize_between_processes(self): + """ + Warning: does not synchronize the deque! + """ + if not is_dist_avail_and_initialized(): + return + t = torch.tensor([self.count, self.total], dtype=torch.float64, device="cuda") + dist.barrier() + dist.all_reduce(t) + t = t.tolist() + self.count = int(t[0]) + self.total = t[1] + + @property + def median(self): + d = torch.tensor(list(self.deque)) + if d.shape[0] == 0: + return 0 + return d.median().item() + + @property + def avg(self): + d = torch.tensor(list(self.deque), dtype=torch.float32) + return d.mean().item() + + @property + def global_avg(self): + if os.environ.get("SHILONG_AMP", None) == "1": + eps = 1e-4 + else: + eps = 1e-6 + return self.total / (self.count + eps) + + @property + def max(self): + return max(self.deque) + + @property + def value(self): + return self.deque[-1] + + def __str__(self): + return self.fmt.format( + median=self.median, + avg=self.avg, + global_avg=self.global_avg, + max=self.max, + value=self.value, + ) + + +@functools.lru_cache() +def _get_global_gloo_group(): + """ + Return a process group based on gloo backend, containing all the ranks + The result is cached. + """ + + if dist.get_backend() == "nccl": + return dist.new_group(backend="gloo") + + return dist.group.WORLD + + +def all_gather_cpu(data): + """ + Run all_gather on arbitrary picklable data (not necessarily tensors) + Args: + data: any picklable object + Returns: + list[data]: list of data gathered from each rank + """ + + world_size = get_world_size() + if world_size == 1: + return [data] + + cpu_group = _get_global_gloo_group() + + buffer = io.BytesIO() + torch.save(data, buffer) + data_view = buffer.getbuffer() + device = "cuda" if cpu_group is None else "cpu" + tensor = torch.ByteTensor(data_view).to(device) + + # obtain Tensor size of each rank + local_size = torch.tensor([tensor.numel()], device=device, dtype=torch.long) + size_list = [torch.tensor([0], device=device, dtype=torch.long) for _ in range(world_size)] + if cpu_group is None: + dist.all_gather(size_list, local_size) + else: + print("gathering on cpu") + dist.all_gather(size_list, local_size, group=cpu_group) + size_list = [int(size.item()) for size in size_list] + max_size = max(size_list) + assert isinstance(local_size.item(), int) + local_size = int(local_size.item()) + + # receiving Tensor from all ranks + # we pad the tensor because torch all_gather does not support + # gathering tensors of different shapes + tensor_list = [] + for _ in size_list: + tensor_list.append(torch.empty((max_size,), dtype=torch.uint8, device=device)) + if local_size != max_size: + padding = torch.empty(size=(max_size - local_size,), dtype=torch.uint8, device=device) + tensor = torch.cat((tensor, padding), dim=0) + if cpu_group is None: + dist.all_gather(tensor_list, tensor) + else: + dist.all_gather(tensor_list, tensor, group=cpu_group) + + data_list = [] + for size, tensor in zip(size_list, tensor_list): + tensor = torch.split(tensor, [size, max_size - size], dim=0)[0] + buffer = io.BytesIO(tensor.cpu().numpy()) + obj = torch.load(buffer) + data_list.append(obj) + + return data_list + + +def all_gather(data): + """ + Run all_gather on arbitrary picklable data (not necessarily tensors) + Args: + data: any picklable object + Returns: + list[data]: list of data gathered from each rank + """ + + if os.getenv("CPU_REDUCE") == "1": + return all_gather_cpu(data) + + world_size = get_world_size() + if world_size == 1: + return [data] + + # serialized to a Tensor + buffer = pickle.dumps(data) + storage = torch.ByteStorage.from_buffer(buffer) + tensor = torch.ByteTensor(storage).to("cuda") + + # obtain Tensor size of each rank + local_size = torch.tensor([tensor.numel()], device="cuda") + size_list = [torch.tensor([0], device="cuda") for _ in range(world_size)] + dist.all_gather(size_list, local_size) + size_list = [int(size.item()) for size in size_list] + max_size = max(size_list) + + # receiving Tensor from all ranks + # we pad the tensor because torch all_gather does not support + # gathering tensors of different shapes + tensor_list = [] + for _ in size_list: + tensor_list.append(torch.empty((max_size,), dtype=torch.uint8, device="cuda")) + if local_size != max_size: + padding = torch.empty(size=(max_size - local_size,), dtype=torch.uint8, device="cuda") + tensor = torch.cat((tensor, padding), dim=0) + dist.all_gather(tensor_list, tensor) + + data_list = [] + for size, tensor in zip(size_list, tensor_list): + buffer = tensor.cpu().numpy().tobytes()[:size] + data_list.append(pickle.loads(buffer)) + + return data_list + + +def reduce_dict(input_dict, average=True): + """ + Args: + input_dict (dict): all the values will be reduced + average (bool): whether to do average or sum + Reduce the values in the dictionary from all processes so that all processes + have the averaged results. Returns a dict with the same fields as + input_dict, after reduction. + """ + world_size = get_world_size() + if world_size < 2: + return input_dict + with torch.no_grad(): + names = [] + values = [] + # sort the keys so that they are consistent across processes + for k in sorted(input_dict.keys()): + names.append(k) + values.append(input_dict[k]) + values = torch.stack(values, dim=0) + dist.all_reduce(values) + if average: + values /= world_size + reduced_dict = {k: v for k, v in zip(names, values)} + return reduced_dict + + +class MetricLogger(object): + def __init__(self, delimiter="\t"): + self.meters = defaultdict(SmoothedValue) + self.delimiter = delimiter + + def update(self, **kwargs): + for k, v in kwargs.items(): + if isinstance(v, torch.Tensor): + v = v.item() + assert isinstance(v, (float, int)) + self.meters[k].update(v) + + def __getattr__(self, attr): + if attr in self.meters: + return self.meters[attr] + if attr in self.__dict__: + return self.__dict__[attr] + raise AttributeError("'{}' object has no attribute '{}'".format(type(self).__name__, attr)) + + def __str__(self): + loss_str = [] + for name, meter in self.meters.items(): + # print(name, str(meter)) + # import ipdb;ipdb.set_trace() + if meter.count > 0: + loss_str.append("{}: {}".format(name, str(meter))) + return self.delimiter.join(loss_str) + + def synchronize_between_processes(self): + for meter in self.meters.values(): + meter.synchronize_between_processes() + + def add_meter(self, name, meter): + self.meters[name] = meter + + def log_every(self, iterable, print_freq, header=None, logger=None): + if logger is None: + print_func = print + else: + print_func = logger.info + + i = 0 + if not header: + header = "" + start_time = time.time() + end = time.time() + iter_time = SmoothedValue(fmt="{avg:.4f}") + data_time = SmoothedValue(fmt="{avg:.4f}") + space_fmt = ":" + str(len(str(len(iterable)))) + "d" + if torch.cuda.is_available(): + log_msg = self.delimiter.join( + [ + header, + "[{0" + space_fmt + "}/{1}]", + "eta: {eta}", + "{meters}", + "time: {time}", + "data: {data}", + "max mem: {memory:.0f}", + ] + ) + else: + log_msg = self.delimiter.join( + [ + header, + "[{0" + space_fmt + "}/{1}]", + "eta: {eta}", + "{meters}", + "time: {time}", + "data: {data}", + ] + ) + MB = 1024.0 * 1024.0 + for obj in iterable: + data_time.update(time.time() - end) + yield obj + # import ipdb; ipdb.set_trace() + iter_time.update(time.time() - end) + if i % print_freq == 0 or i == len(iterable) - 1: + eta_seconds = iter_time.global_avg * (len(iterable) - i) + eta_string = str(datetime.timedelta(seconds=int(eta_seconds))) + if torch.cuda.is_available(): + print_func( + log_msg.format( + i, + len(iterable), + eta=eta_string, + meters=str(self), + time=str(iter_time), + data=str(data_time), + memory=torch.cuda.max_memory_allocated() / MB, + ) + ) + else: + print_func( + log_msg.format( + i, + len(iterable), + eta=eta_string, + meters=str(self), + time=str(iter_time), + data=str(data_time), + ) + ) + i += 1 + end = time.time() + total_time = time.time() - start_time + total_time_str = str(datetime.timedelta(seconds=int(total_time))) + print_func( + "{} Total time: {} ({:.4f} s / it)".format( + header, total_time_str, total_time / len(iterable) + ) + ) + + +def get_sha(): + cwd = os.path.dirname(os.path.abspath(__file__)) + + def _run(command): + return subprocess.check_output(command, cwd=cwd).decode("ascii").strip() + + sha = "N/A" + diff = "clean" + branch = "N/A" + try: + sha = _run(["git", "rev-parse", "HEAD"]) + subprocess.check_output(["git", "diff"], cwd=cwd) + diff = _run(["git", "diff-index", "HEAD"]) + diff = "has uncommited changes" if diff else "clean" + branch = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"]) + except Exception: + pass + message = f"sha: {sha}, status: {diff}, branch: {branch}" + return message + + +def collate_fn(batch): + # import ipdb; ipdb.set_trace() + batch = list(zip(*batch)) + batch[0] = nested_tensor_from_tensor_list(batch[0]) + return tuple(batch) + + +def _max_by_axis(the_list): + # type: (List[List[int]]) -> List[int] + maxes = the_list[0] + for sublist in the_list[1:]: + for index, item in enumerate(sublist): + maxes[index] = max(maxes[index], item) + return maxes + + +class NestedTensor(object): + def __init__(self, tensors, mask: Optional[Tensor]): + self.tensors = tensors + self.mask = mask + if mask == "auto": + self.mask = torch.zeros_like(tensors).to(tensors.device) + if self.mask.dim() == 3: + self.mask = self.mask.sum(0).to(bool) + elif self.mask.dim() == 4: + self.mask = self.mask.sum(1).to(bool) + else: + raise ValueError( + "tensors dim must be 3 or 4 but {}({})".format( + self.tensors.dim(), self.tensors.shape + ) + ) + + def imgsize(self): + res = [] + for i in range(self.tensors.shape[0]): + mask = self.mask[i] + maxH = (~mask).sum(0).max() + maxW = (~mask).sum(1).max() + res.append(torch.Tensor([maxH, maxW])) + return res + + def to(self, device): + # type: (Device) -> NestedTensor # noqa + cast_tensor = self.tensors.to(device) + mask = self.mask + if mask is not None: + assert mask is not None + cast_mask = mask.to(device) + else: + cast_mask = None + return NestedTensor(cast_tensor, cast_mask) + + def to_img_list_single(self, tensor, mask): + assert tensor.dim() == 3, "dim of tensor should be 3 but {}".format(tensor.dim()) + maxH = (~mask).sum(0).max() + maxW = (~mask).sum(1).max() + img = tensor[:, :maxH, :maxW] + return img + + def to_img_list(self): + """remove the padding and convert to img list + + Returns: + [type]: [description] + """ + if self.tensors.dim() == 3: + return self.to_img_list_single(self.tensors, self.mask) + else: + res = [] + for i in range(self.tensors.shape[0]): + tensor_i = self.tensors[i] + mask_i = self.mask[i] + res.append(self.to_img_list_single(tensor_i, mask_i)) + return res + + @property + def device(self): + return self.tensors.device + + def decompose(self): + return self.tensors, self.mask + + def __repr__(self): + return str(self.tensors) + + @property + def shape(self): + return {"tensors.shape": self.tensors.shape, "mask.shape": self.mask.shape} + + +def nested_tensor_from_tensor_list(tensor_list: List[Tensor]): + # TODO make this more general + if tensor_list[0].ndim == 3: + if torchvision._is_tracing(): + # nested_tensor_from_tensor_list() does not export well to ONNX + # call _onnx_nested_tensor_from_tensor_list() instead + return _onnx_nested_tensor_from_tensor_list(tensor_list) + + # TODO make it support different-sized images + max_size = _max_by_axis([list(img.shape) for img in tensor_list]) + # min_size = tuple(min(s) for s in zip(*[img.shape for img in tensor_list])) + batch_shape = [len(tensor_list)] + max_size + b, c, h, w = batch_shape + dtype = tensor_list[0].dtype + device = tensor_list[0].device + tensor = torch.zeros(batch_shape, dtype=dtype, device=device) + mask = torch.ones((b, h, w), dtype=torch.bool, device=device) + for img, pad_img, m in zip(tensor_list, tensor, mask): + pad_img[: img.shape[0], : img.shape[1], : img.shape[2]].copy_(img) + m[: img.shape[1], : img.shape[2]] = False + else: + raise ValueError("not supported") + return NestedTensor(tensor, mask) + + +# _onnx_nested_tensor_from_tensor_list() is an implementation of +# nested_tensor_from_tensor_list() that is supported by ONNX tracing. +@torch.jit.unused +def _onnx_nested_tensor_from_tensor_list(tensor_list: List[Tensor]) -> NestedTensor: + max_size = [] + for i in range(tensor_list[0].dim()): + max_size_i = torch.max( + torch.stack([img.shape[i] for img in tensor_list]).to(torch.float32) + ).to(torch.int64) + max_size.append(max_size_i) + max_size = tuple(max_size) + + # work around for + # pad_img[: img.shape[0], : img.shape[1], : img.shape[2]].copy_(img) + # m[: img.shape[1], :img.shape[2]] = False + # which is not yet supported in onnx + padded_imgs = [] + padded_masks = [] + for img in tensor_list: + padding = [(s1 - s2) for s1, s2 in zip(max_size, tuple(img.shape))] + padded_img = torch.nn.functional.pad(img, (0, padding[2], 0, padding[1], 0, padding[0])) + padded_imgs.append(padded_img) + + m = torch.zeros_like(img[0], dtype=torch.int, device=img.device) + padded_mask = torch.nn.functional.pad(m, (0, padding[2], 0, padding[1]), "constant", 1) + padded_masks.append(padded_mask.to(torch.bool)) + + tensor = torch.stack(padded_imgs) + mask = torch.stack(padded_masks) + + return NestedTensor(tensor, mask=mask) + + +def setup_for_distributed(is_master): + """ + This function disables printing when not in master process + """ + import builtins as __builtin__ + + builtin_print = __builtin__.print + + def print(*args, **kwargs): + force = kwargs.pop("force", False) + if is_master or force: + builtin_print(*args, **kwargs) + + __builtin__.print = print + + +def is_dist_avail_and_initialized(): + if not dist.is_available(): + return False + if not dist.is_initialized(): + return False + return True + + +def get_world_size(): + if not is_dist_avail_and_initialized(): + return 1 + return dist.get_world_size() + + +def get_rank(): + if not is_dist_avail_and_initialized(): + return 0 + return dist.get_rank() + + +def is_main_process(): + return get_rank() == 0 + + +def save_on_master(*args, **kwargs): + if is_main_process(): + torch.save(*args, **kwargs) + + +def init_distributed_mode(args): + if "WORLD_SIZE" in os.environ and os.environ["WORLD_SIZE"] != "": # 'RANK' in os.environ and + args.rank = int(os.environ["RANK"]) + args.world_size = int(os.environ["WORLD_SIZE"]) + args.gpu = args.local_rank = int(os.environ["LOCAL_RANK"]) + + # launch by torch.distributed.launch + # Single node + # python -m torch.distributed.launch --nproc_per_node=8 main.py --world-size 1 --rank 0 ... + # Multi nodes + # python -m torch.distributed.launch --nproc_per_node=8 main.py --world-size 2 --rank 0 --dist-url 'tcp://IP_OF_NODE0:FREEPORT' ... + # python -m torch.distributed.launch --nproc_per_node=8 main.py --world-size 2 --rank 1 --dist-url 'tcp://IP_OF_NODE0:FREEPORT' ... + # args.rank = int(os.environ.get('OMPI_COMM_WORLD_RANK')) + # local_world_size = int(os.environ['GPU_PER_NODE_COUNT']) + # args.world_size = args.world_size * local_world_size + # args.gpu = args.local_rank = int(os.environ['LOCAL_RANK']) + # args.rank = args.rank * local_world_size + args.local_rank + print( + "world size: {}, rank: {}, local rank: {}".format( + args.world_size, args.rank, args.local_rank + ) + ) + print(json.dumps(dict(os.environ), indent=2)) + elif "SLURM_PROCID" in os.environ: + args.rank = int(os.environ["SLURM_PROCID"]) + args.gpu = args.local_rank = int(os.environ["SLURM_LOCALID"]) + args.world_size = int(os.environ["SLURM_NPROCS"]) + + print( + "world size: {}, world rank: {}, local rank: {}, device_count: {}".format( + args.world_size, args.rank, args.local_rank, torch.cuda.device_count() + ) + ) + else: + print("Not using distributed mode") + args.distributed = False + args.world_size = 1 + args.rank = 0 + args.local_rank = 0 + return + + print("world_size:{} rank:{} local_rank:{}".format(args.world_size, args.rank, args.local_rank)) + args.distributed = True + torch.cuda.set_device(args.local_rank) + args.dist_backend = "nccl" + print("| distributed init (rank {}): {}".format(args.rank, args.dist_url), flush=True) + + torch.distributed.init_process_group( + backend=args.dist_backend, + world_size=args.world_size, + rank=args.rank, + init_method=args.dist_url, + ) + + print("Before torch.distributed.barrier()") + torch.distributed.barrier() + print("End torch.distributed.barrier()") + setup_for_distributed(args.rank == 0) + + +@torch.no_grad() +def accuracy(output, target, topk=(1,)): + """Computes the precision@k for the specified values of k""" + if target.numel() == 0: + return [torch.zeros([], device=output.device)] + maxk = max(topk) + batch_size = target.size(0) + + _, pred = output.topk(maxk, 1, True, True) + pred = pred.t() + correct = pred.eq(target.view(1, -1).expand_as(pred)) + + res = [] + for k in topk: + correct_k = correct[:k].view(-1).float().sum(0) + res.append(correct_k.mul_(100.0 / batch_size)) + return res + + +@torch.no_grad() +def accuracy_onehot(pred, gt): + """_summary_ + + Args: + pred (_type_): n, c + gt (_type_): n, c + """ + tp = ((pred - gt).abs().sum(-1) < 1e-4).float().sum() + acc = tp / gt.shape[0] * 100 + return acc + + +def interpolate(input, size=None, scale_factor=None, mode="nearest", align_corners=None): + # type: (Tensor, Optional[List[int]], Optional[float], str, Optional[bool]) -> Tensor + """ + Equivalent to nn.functional.interpolate, but with support for empty batch sizes. + This will eventually be supported natively by PyTorch, and this + class can go away. + """ + if __torchvision_need_compat_flag < 0.7: + if input.numel() > 0: + return torch.nn.functional.interpolate(input, size, scale_factor, mode, align_corners) + + output_shape = _output_size(2, input, size, scale_factor) + output_shape = list(input.shape[:-2]) + list(output_shape) + return _new_empty_tensor(input, output_shape) + else: + return torchvision.ops.misc.interpolate(input, size, scale_factor, mode, align_corners) + + +class color_sys: + def __init__(self, num_colors) -> None: + self.num_colors = num_colors + colors = [] + for i in np.arange(0.0, 360.0, 360.0 / num_colors): + hue = i / 360.0 + lightness = (50 + np.random.rand() * 10) / 100.0 + saturation = (90 + np.random.rand() * 10) / 100.0 + colors.append( + tuple([int(j * 255) for j in colorsys.hls_to_rgb(hue, lightness, saturation)]) + ) + self.colors = colors + + def __call__(self, idx): + return self.colors[idx] + + +def inverse_sigmoid(x, eps=1e-3): + x = x.clamp(min=0, max=1) + x1 = x.clamp(min=eps) + x2 = (1 - x).clamp(min=eps) + return torch.log(x1 / x2) + + +def clean_state_dict(state_dict): + new_state_dict = OrderedDict() + for k, v in state_dict.items(): + if k[:7] == "module.": + k = k[7:] # remove `module.` + new_state_dict[k] = v + return new_state_dict + diff --git a/simple_syrup/third_party/groundingdino_runtime/util/slconfig.py b/simple_syrup/third_party/groundingdino_runtime/util/slconfig.py new file mode 100644 index 0000000..0f386f1 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/util/slconfig.py @@ -0,0 +1,428 @@ +# ========================================================== +# Modified from mmcv +# ========================================================== +import ast +import os +import os.path as osp +import shutil +import sys +import tempfile +from argparse import Action +from importlib import import_module + +from addict import Dict +from yapf.yapflib.yapf_api import FormatCode + +BASE_KEY = "_base_" +DELETE_KEY = "_delete_" +RESERVED_KEYS = ["filename", "text", "pretty_text", "get", "dump", "merge_from_dict"] + + +def check_file_exist(filename, msg_tmpl='file "{}" does not exist'): + if not osp.isfile(filename): + raise FileNotFoundError(msg_tmpl.format(filename)) + + +class ConfigDict(Dict): + def __missing__(self, name): + raise KeyError(name) + + def __getattr__(self, name): + try: + value = super(ConfigDict, self).__getattr__(name) + except KeyError: + ex = AttributeError(f"'{self.__class__.__name__}' object has no " f"attribute '{name}'") + except Exception as e: + ex = e + else: + return value + raise ex + + +class SLConfig(object): + """ + config files. + only support .py file as config now. + + ref: mmcv.utils.config + + Example: + >>> cfg = Config(dict(a=1, b=dict(b1=[0, 1]))) + >>> cfg.a + 1 + >>> cfg.b + {'b1': [0, 1]} + >>> cfg.b.b1 + [0, 1] + >>> cfg = Config.fromfile('tests/data/config/a.py') + >>> cfg.filename + "/home/kchen/projects/mmcv/tests/data/config/a.py" + >>> cfg.item4 + 'test' + >>> cfg + "Config [path: /home/kchen/projects/mmcv/tests/data/config/a.py]: " + "{'item1': [1, 2], 'item2': {'a': 0}, 'item3': True, 'item4': 'test'}" + """ + + @staticmethod + def _validate_py_syntax(filename): + with open(filename) as f: + content = f.read() + try: + ast.parse(content) + except SyntaxError: + raise SyntaxError("There are syntax errors in config " f"file {filename}") + + @staticmethod + def _file2dict(filename): + filename = osp.abspath(osp.expanduser(filename)) + check_file_exist(filename) + if filename.lower().endswith(".py"): + with tempfile.TemporaryDirectory() as temp_config_dir: + temp_config_file = tempfile.NamedTemporaryFile(dir=temp_config_dir, suffix=".py") + temp_config_name = osp.basename(temp_config_file.name) + if os.name == 'nt': + temp_config_file.close() + shutil.copyfile(filename, osp.join(temp_config_dir, temp_config_name)) + temp_module_name = osp.splitext(temp_config_name)[0] + sys.path.insert(0, temp_config_dir) + SLConfig._validate_py_syntax(filename) + mod = import_module(temp_module_name) + sys.path.pop(0) + cfg_dict = { + name: value for name, value in mod.__dict__.items() if not name.startswith("__") + } + # delete imported module + del sys.modules[temp_module_name] + # close temp file + temp_config_file.close() + elif filename.lower().endswith((".yml", ".yaml", ".json")): + from .slio import slload + + cfg_dict = slload(filename) + else: + raise IOError("Only py/yml/yaml/json type are supported now!") + + cfg_text = filename + "\n" + with open(filename, "r") as f: + cfg_text += f.read() + + # parse the base file + if BASE_KEY in cfg_dict: + cfg_dir = osp.dirname(filename) + base_filename = cfg_dict.pop(BASE_KEY) + base_filename = base_filename if isinstance(base_filename, list) else [base_filename] + + cfg_dict_list = list() + cfg_text_list = list() + for f in base_filename: + _cfg_dict, _cfg_text = SLConfig._file2dict(osp.join(cfg_dir, f)) + cfg_dict_list.append(_cfg_dict) + cfg_text_list.append(_cfg_text) + + base_cfg_dict = dict() + for c in cfg_dict_list: + if len(base_cfg_dict.keys() & c.keys()) > 0: + raise KeyError("Duplicate key is not allowed among bases") + # TODO Allow the duplicate key while warnning user + base_cfg_dict.update(c) + + base_cfg_dict = SLConfig._merge_a_into_b(cfg_dict, base_cfg_dict) + cfg_dict = base_cfg_dict + + # merge cfg_text + cfg_text_list.append(cfg_text) + cfg_text = "\n".join(cfg_text_list) + + return cfg_dict, cfg_text + + @staticmethod + def _merge_a_into_b(a, b): + """merge dict `a` into dict `b` (non-inplace). + values in `a` will overwrite `b`. + copy first to avoid inplace modification + + Args: + a ([type]): [description] + b ([type]): [description] + + Returns: + [dict]: [description] + """ + # import ipdb; ipdb.set_trace() + if not isinstance(a, dict): + return a + + b = b.copy() + for k, v in a.items(): + if isinstance(v, dict) and k in b and not v.pop(DELETE_KEY, False): + + if not isinstance(b[k], dict) and not isinstance(b[k], list): + # if : + # import ipdb; ipdb.set_trace() + raise TypeError( + f"{k}={v} in child config cannot inherit from base " + f"because {k} is a dict in the child config but is of " + f"type {type(b[k])} in base config. You may set " + f"`{DELETE_KEY}=True` to ignore the base config" + ) + b[k] = SLConfig._merge_a_into_b(v, b[k]) + elif isinstance(b, list): + try: + _ = int(k) + except: + raise TypeError( + f"b is a list, " f"index {k} should be an int when input but {type(k)}" + ) + b[int(k)] = SLConfig._merge_a_into_b(v, b[int(k)]) + else: + b[k] = v + + return b + + @staticmethod + def fromfile(filename): + cfg_dict, cfg_text = SLConfig._file2dict(filename) + return SLConfig(cfg_dict, cfg_text=cfg_text, filename=filename) + + def __init__(self, cfg_dict=None, cfg_text=None, filename=None): + if cfg_dict is None: + cfg_dict = dict() + elif not isinstance(cfg_dict, dict): + raise TypeError("cfg_dict must be a dict, but " f"got {type(cfg_dict)}") + for key in cfg_dict: + if key in RESERVED_KEYS: + raise KeyError(f"{key} is reserved for config file") + + super(SLConfig, self).__setattr__("_cfg_dict", ConfigDict(cfg_dict)) + super(SLConfig, self).__setattr__("_filename", filename) + if cfg_text: + text = cfg_text + elif filename: + with open(filename, "r") as f: + text = f.read() + else: + text = "" + super(SLConfig, self).__setattr__("_text", text) + + @property + def filename(self): + return self._filename + + @property + def text(self): + return self._text + + @property + def pretty_text(self): + + indent = 4 + + def _indent(s_, num_spaces): + s = s_.split("\n") + if len(s) == 1: + return s_ + first = s.pop(0) + s = [(num_spaces * " ") + line for line in s] + s = "\n".join(s) + s = first + "\n" + s + return s + + def _format_basic_types(k, v, use_mapping=False): + if isinstance(v, str): + v_str = f"'{v}'" + else: + v_str = str(v) + + if use_mapping: + k_str = f"'{k}'" if isinstance(k, str) else str(k) + attr_str = f"{k_str}: {v_str}" + else: + attr_str = f"{str(k)}={v_str}" + attr_str = _indent(attr_str, indent) + + return attr_str + + def _format_list(k, v, use_mapping=False): + # check if all items in the list are dict + if all(isinstance(_, dict) for _ in v): + v_str = "[\n" + v_str += "\n".join( + f"dict({_indent(_format_dict(v_), indent)})," for v_ in v + ).rstrip(",") + if use_mapping: + k_str = f"'{k}'" if isinstance(k, str) else str(k) + attr_str = f"{k_str}: {v_str}" + else: + attr_str = f"{str(k)}={v_str}" + attr_str = _indent(attr_str, indent) + "]" + else: + attr_str = _format_basic_types(k, v, use_mapping) + return attr_str + + def _contain_invalid_identifier(dict_str): + contain_invalid_identifier = False + for key_name in dict_str: + contain_invalid_identifier |= not str(key_name).isidentifier() + return contain_invalid_identifier + + def _format_dict(input_dict, outest_level=False): + r = "" + s = [] + + use_mapping = _contain_invalid_identifier(input_dict) + if use_mapping: + r += "{" + for idx, (k, v) in enumerate(input_dict.items()): + is_last = idx >= len(input_dict) - 1 + end = "" if outest_level or is_last else "," + if isinstance(v, dict): + v_str = "\n" + _format_dict(v) + if use_mapping: + k_str = f"'{k}'" if isinstance(k, str) else str(k) + attr_str = f"{k_str}: dict({v_str}" + else: + attr_str = f"{str(k)}=dict({v_str}" + attr_str = _indent(attr_str, indent) + ")" + end + elif isinstance(v, list): + attr_str = _format_list(k, v, use_mapping) + end + else: + attr_str = _format_basic_types(k, v, use_mapping) + end + + s.append(attr_str) + r += "\n".join(s) + if use_mapping: + r += "}" + return r + + cfg_dict = self._cfg_dict.to_dict() + text = _format_dict(cfg_dict, outest_level=True) + # copied from setup.cfg + yapf_style = dict( + based_on_style="pep8", + blank_line_before_nested_class_or_def=True, + split_before_expression_after_opening_paren=True, + ) + text, _ = FormatCode(text, style_config=yapf_style, verify=True) + + return text + + def __repr__(self): + return f"Config (path: {self.filename}): {self._cfg_dict.__repr__()}" + + def __len__(self): + return len(self._cfg_dict) + + def __getattr__(self, name): + # # debug + # print('+'*15) + # print('name=%s' % name) + # print("addr:", id(self)) + # # print('type(self):', type(self)) + # print(self.__dict__) + # print('+'*15) + # if self.__dict__ == {}: + # raise ValueError + + return getattr(self._cfg_dict, name) + + def __getitem__(self, name): + return self._cfg_dict.__getitem__(name) + + def __setattr__(self, name, value): + if isinstance(value, dict): + value = ConfigDict(value) + self._cfg_dict.__setattr__(name, value) + + def __setitem__(self, name, value): + if isinstance(value, dict): + value = ConfigDict(value) + self._cfg_dict.__setitem__(name, value) + + def __iter__(self): + return iter(self._cfg_dict) + + def dump(self, file=None): + # import ipdb; ipdb.set_trace() + if file is None: + return self.pretty_text + else: + with open(file, "w") as f: + f.write(self.pretty_text) + + def merge_from_dict(self, options): + """Merge list into cfg_dict + + Merge the dict parsed by MultipleKVAction into this cfg. + + Examples: + >>> options = {'model.backbone.depth': 50, + ... 'model.backbone.with_cp':True} + >>> cfg = Config(dict(model=dict(backbone=dict(type='ResNet')))) + >>> cfg.merge_from_dict(options) + >>> cfg_dict = super(Config, self).__getattribute__('_cfg_dict') + >>> assert cfg_dict == dict( + ... model=dict(backbone=dict(depth=50, with_cp=True))) + + Args: + options (dict): dict of configs to merge from. + """ + option_cfg_dict = {} + for full_key, v in options.items(): + d = option_cfg_dict + key_list = full_key.split(".") + for subkey in key_list[:-1]: + d.setdefault(subkey, ConfigDict()) + d = d[subkey] + subkey = key_list[-1] + d[subkey] = v + + cfg_dict = super(SLConfig, self).__getattribute__("_cfg_dict") + super(SLConfig, self).__setattr__( + "_cfg_dict", SLConfig._merge_a_into_b(option_cfg_dict, cfg_dict) + ) + + # for multiprocess + def __setstate__(self, state): + self.__init__(state) + + def copy(self): + return SLConfig(self._cfg_dict.copy()) + + def deepcopy(self): + return SLConfig(self._cfg_dict.deepcopy()) + + +class DictAction(Action): + """ + argparse action to split an argument into KEY=VALUE form + on the first = and append to a dictionary. List options should + be passed as comma separated values, i.e KEY=V1,V2,V3 + """ + + @staticmethod + def _parse_int_float_bool(val): + try: + return int(val) + except ValueError: + pass + try: + return float(val) + except ValueError: + pass + if val.lower() in ["true", "false"]: + return True if val.lower() == "true" else False + if val.lower() in ["none", "null"]: + return None + return val + + def __call__(self, parser, namespace, values, option_string=None): + options = {} + for kv in values: + key, val = kv.split("=", maxsplit=1) + val = [self._parse_int_float_bool(v) for v in val.split(",")] + if len(val) == 1: + val = val[0] + options[key] = val + setattr(namespace, self.dest, options) + diff --git a/simple_syrup/third_party/groundingdino_runtime/util/slio.py b/simple_syrup/third_party/groundingdino_runtime/util/slio.py new file mode 100644 index 0000000..9e69904 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/util/slio.py @@ -0,0 +1,178 @@ +# ========================================================== +# Modified from mmcv +# ========================================================== + +import json +import pickle +from abc import ABCMeta, abstractmethod +from pathlib import Path + +import yaml + +try: + from yaml import CLoader as Loader, CDumper as Dumper +except ImportError: + from yaml import Loader, Dumper + + +# =========================== +# Rigister handler +# =========================== + + +class BaseFileHandler(metaclass=ABCMeta): + @abstractmethod + def load_from_fileobj(self, file, **kwargs): + pass + + @abstractmethod + def dump_to_fileobj(self, obj, file, **kwargs): + pass + + @abstractmethod + def dump_to_str(self, obj, **kwargs): + pass + + def load_from_path(self, filepath, mode="r", **kwargs): + with open(filepath, mode) as f: + return self.load_from_fileobj(f, **kwargs) + + def dump_to_path(self, obj, filepath, mode="w", **kwargs): + with open(filepath, mode) as f: + self.dump_to_fileobj(obj, f, **kwargs) + + +class JsonHandler(BaseFileHandler): + def load_from_fileobj(self, file): + return json.load(file) + + def dump_to_fileobj(self, obj, file, **kwargs): + json.dump(obj, file, **kwargs) + + def dump_to_str(self, obj, **kwargs): + return json.dumps(obj, **kwargs) + + +class PickleHandler(BaseFileHandler): + def load_from_fileobj(self, file, **kwargs): + return pickle.load(file, **kwargs) + + def load_from_path(self, filepath, **kwargs): + return super(PickleHandler, self).load_from_path(filepath, mode="rb", **kwargs) + + def dump_to_str(self, obj, **kwargs): + kwargs.setdefault("protocol", 2) + return pickle.dumps(obj, **kwargs) + + def dump_to_fileobj(self, obj, file, **kwargs): + kwargs.setdefault("protocol", 2) + pickle.dump(obj, file, **kwargs) + + def dump_to_path(self, obj, filepath, **kwargs): + super(PickleHandler, self).dump_to_path(obj, filepath, mode="wb", **kwargs) + + +class YamlHandler(BaseFileHandler): + def load_from_fileobj(self, file, **kwargs): + kwargs.setdefault("Loader", Loader) + return yaml.load(file, **kwargs) + + def dump_to_fileobj(self, obj, file, **kwargs): + kwargs.setdefault("Dumper", Dumper) + yaml.dump(obj, file, **kwargs) + + def dump_to_str(self, obj, **kwargs): + kwargs.setdefault("Dumper", Dumper) + return yaml.dump(obj, **kwargs) + + +file_handlers = { + "json": JsonHandler(), + "yaml": YamlHandler(), + "yml": YamlHandler(), + "pickle": PickleHandler(), + "pkl": PickleHandler(), +} + +# =========================== +# load and dump +# =========================== + + +def is_str(x): + """Whether the input is an string instance. + + Note: This method is deprecated since python 2 is no longer supported. + """ + return isinstance(x, str) + + +def slload(file, file_format=None, **kwargs): + """Load data from json/yaml/pickle files. + + This method provides a unified api for loading data from serialized files. + + Args: + file (str or :obj:`Path` or file-like object): Filename or a file-like + object. + file_format (str, optional): If not specified, the file format will be + inferred from the file extension, otherwise use the specified one. + Currently supported formats include "json", "yaml/yml" and + "pickle/pkl". + + Returns: + The content from the file. + """ + if isinstance(file, Path): + file = str(file) + if file_format is None and is_str(file): + file_format = file.split(".")[-1] + if file_format not in file_handlers: + raise TypeError(f"Unsupported format: {file_format}") + + handler = file_handlers[file_format] + if is_str(file): + obj = handler.load_from_path(file, **kwargs) + elif hasattr(file, "read"): + obj = handler.load_from_fileobj(file, **kwargs) + else: + raise TypeError('"file" must be a filepath str or a file-object') + return obj + + +def sldump(obj, file=None, file_format=None, **kwargs): + """Dump data to json/yaml/pickle strings or files. + + This method provides a unified api for dumping data as strings or to files, + and also supports custom arguments for each file format. + + Args: + obj (any): The python object to be dumped. + file (str or :obj:`Path` or file-like object, optional): If not + specified, then the object is dump to a str, otherwise to a file + specified by the filename or file-like object. + file_format (str, optional): Same as :func:`load`. + + Returns: + bool: True for success, False otherwise. + """ + if isinstance(file, Path): + file = str(file) + if file_format is None: + if is_str(file): + file_format = file.split(".")[-1] + elif file is None: + raise ValueError("file_format must be specified since file is None") + if file_format not in file_handlers: + raise TypeError(f"Unsupported format: {file_format}") + + handler = file_handlers[file_format] + if file is None: + return handler.dump_to_str(obj, **kwargs) + elif is_str(file): + handler.dump_to_path(obj, file, **kwargs) + elif hasattr(file, "write"): + handler.dump_to_fileobj(obj, file, **kwargs) + else: + raise TypeError('"file" must be a filename str or a file-object') + diff --git a/simple_syrup/third_party/groundingdino_runtime/util/utils.py b/simple_syrup/third_party/groundingdino_runtime/util/utils.py new file mode 100644 index 0000000..0aa3dc6 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/util/utils.py @@ -0,0 +1,609 @@ +import argparse +import json +import warnings +from collections import OrderedDict +from copy import deepcopy +from typing import Any, Dict, List + +import numpy as np +import torch +from transformers import AutoTokenizer + +from simple_syrup.third_party.groundingdino_runtime.util.slconfig import SLConfig + + +def slprint(x, name="x"): + if isinstance(x, (torch.Tensor, np.ndarray)): + print(f"{name}.shape:", x.shape) + elif isinstance(x, (tuple, list)): + print("type x:", type(x)) + for i in range(min(10, len(x))): + slprint(x[i], f"{name}[{i}]") + elif isinstance(x, dict): + for k, v in x.items(): + slprint(v, f"{name}[{k}]") + else: + print(f"{name}.type:", type(x)) + + +def clean_state_dict(state_dict): + new_state_dict = OrderedDict() + for k, v in state_dict.items(): + if k[:7] == "module.": + k = k[7:] # remove `module.` + new_state_dict[k] = v + return new_state_dict + + +def renorm( + img: torch.FloatTensor, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] +) -> torch.FloatTensor: + # img: tensor(3,H,W) or tensor(B,3,H,W) + # return: same as img + assert img.dim() == 3 or img.dim() == 4, "img.dim() should be 3 or 4 but %d" % img.dim() + if img.dim() == 3: + assert img.size(0) == 3, 'img.size(0) shoule be 3 but "%d". (%s)' % ( + img.size(0), + str(img.size()), + ) + img_perm = img.permute(1, 2, 0) + mean = torch.Tensor(mean) + std = torch.Tensor(std) + img_res = img_perm * std + mean + return img_res.permute(2, 0, 1) + else: # img.dim() == 4 + assert img.size(1) == 3, 'img.size(1) shoule be 3 but "%d". (%s)' % ( + img.size(1), + str(img.size()), + ) + img_perm = img.permute(0, 2, 3, 1) + mean = torch.Tensor(mean) + std = torch.Tensor(std) + img_res = img_perm * std + mean + return img_res.permute(0, 3, 1, 2) + + +class CocoClassMapper: + def __init__(self) -> None: + self.category_map_str = { + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "6": 6, + "7": 7, + "8": 8, + "9": 9, + "10": 10, + "11": 11, + "13": 12, + "14": 13, + "15": 14, + "16": 15, + "17": 16, + "18": 17, + "19": 18, + "20": 19, + "21": 20, + "22": 21, + "23": 22, + "24": 23, + "25": 24, + "27": 25, + "28": 26, + "31": 27, + "32": 28, + "33": 29, + "34": 30, + "35": 31, + "36": 32, + "37": 33, + "38": 34, + "39": 35, + "40": 36, + "41": 37, + "42": 38, + "43": 39, + "44": 40, + "46": 41, + "47": 42, + "48": 43, + "49": 44, + "50": 45, + "51": 46, + "52": 47, + "53": 48, + "54": 49, + "55": 50, + "56": 51, + "57": 52, + "58": 53, + "59": 54, + "60": 55, + "61": 56, + "62": 57, + "63": 58, + "64": 59, + "65": 60, + "67": 61, + "70": 62, + "72": 63, + "73": 64, + "74": 65, + "75": 66, + "76": 67, + "77": 68, + "78": 69, + "79": 70, + "80": 71, + "81": 72, + "82": 73, + "84": 74, + "85": 75, + "86": 76, + "87": 77, + "88": 78, + "89": 79, + "90": 80, + } + self.origin2compact_mapper = {int(k): v - 1 for k, v in self.category_map_str.items()} + self.compact2origin_mapper = {int(v - 1): int(k) for k, v in self.category_map_str.items()} + + def origin2compact(self, idx): + return self.origin2compact_mapper[int(idx)] + + def compact2origin(self, idx): + return self.compact2origin_mapper[int(idx)] + + +def to_device(item, device): + if isinstance(item, torch.Tensor): + return item.to(device) + elif isinstance(item, list): + return [to_device(i, device) for i in item] + elif isinstance(item, dict): + return {k: to_device(v, device) for k, v in item.items()} + else: + raise NotImplementedError( + "Call Shilong if you use other containers! type: {}".format(type(item)) + ) + + +# +def get_gaussian_mean(x, axis, other_axis, softmax=True): + """ + + Args: + x (float): Input images(BxCxHxW) + axis (int): The index for weighted mean + other_axis (int): The other index + + Returns: weighted index for axis, BxC + + """ + mat2line = torch.sum(x, axis=other_axis) + # mat2line = mat2line / mat2line.mean() * 10 + if softmax: + u = torch.softmax(mat2line, axis=2) + else: + u = mat2line / (mat2line.sum(2, keepdim=True) + 1e-6) + size = x.shape[axis] + ind = torch.linspace(0, 1, size).to(x.device) + batch = x.shape[0] + channel = x.shape[1] + index = ind.repeat([batch, channel, 1]) + mean_position = torch.sum(index * u, dim=2) + return mean_position + + +def get_expected_points_from_map(hm, softmax=True): + """get_gaussian_map_from_points + B,C,H,W -> B,N,2 float(0, 1) float(0, 1) + softargmax function + + Args: + hm (float): Input images(BxCxHxW) + + Returns: + weighted index for axis, BxCx2. float between 0 and 1. + + """ + # hm = 10*hm + B, C, H, W = hm.shape + y_mean = get_gaussian_mean(hm, 2, 3, softmax=softmax) # B,C + x_mean = get_gaussian_mean(hm, 3, 2, softmax=softmax) # B,C + # return torch.cat((x_mean.unsqueeze(-1), y_mean.unsqueeze(-1)), 2) + return torch.stack([x_mean, y_mean], dim=2) + + +# Positional encoding (section 5.1) +# borrow from nerf +class Embedder: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.create_embedding_fn() + + def create_embedding_fn(self): + embed_fns = [] + d = self.kwargs["input_dims"] + out_dim = 0 + if self.kwargs["include_input"]: + embed_fns.append(lambda x: x) + out_dim += d + + max_freq = self.kwargs["max_freq_log2"] + N_freqs = self.kwargs["num_freqs"] + + if self.kwargs["log_sampling"]: + freq_bands = 2.0 ** torch.linspace(0.0, max_freq, steps=N_freqs) + else: + freq_bands = torch.linspace(2.0**0.0, 2.0**max_freq, steps=N_freqs) + + for freq in freq_bands: + for p_fn in self.kwargs["periodic_fns"]: + embed_fns.append(lambda x, p_fn=p_fn, freq=freq: p_fn(x * freq)) + out_dim += d + + self.embed_fns = embed_fns + self.out_dim = out_dim + + def embed(self, inputs): + return torch.cat([fn(inputs) for fn in self.embed_fns], -1) + + +def get_embedder(multires, i=0): + import torch.nn as nn + + if i == -1: + return nn.Identity(), 3 + + embed_kwargs = { + "include_input": True, + "input_dims": 3, + "max_freq_log2": multires - 1, + "num_freqs": multires, + "log_sampling": True, + "periodic_fns": [torch.sin, torch.cos], + } + + embedder_obj = Embedder(**embed_kwargs) + embed = lambda x, eo=embedder_obj: eo.embed(x) + return embed, embedder_obj.out_dim + + +class APOPMeter: + def __init__(self) -> None: + self.tp = 0 + self.fp = 0 + self.tn = 0 + self.fn = 0 + + def update(self, pred, gt): + """ + Input: + pred, gt: Tensor() + """ + assert pred.shape == gt.shape + self.tp += torch.logical_and(pred == 1, gt == 1).sum().item() + self.fp += torch.logical_and(pred == 1, gt == 0).sum().item() + self.tn += torch.logical_and(pred == 0, gt == 0).sum().item() + self.tn += torch.logical_and(pred == 1, gt == 0).sum().item() + + def update_cm(self, tp, fp, tn, fn): + self.tp += tp + self.fp += fp + self.tn += tn + self.tn += fn + + +def inverse_sigmoid(x, eps=1e-5): + x = x.clamp(min=0, max=1) + x1 = x.clamp(min=eps) + x2 = (1 - x).clamp(min=eps) + return torch.log(x1 / x2) + + +def get_raw_dict(args): + """ + return the dicf contained in args. + + e.g: + >>> with open(path, 'w') as f: + json.dump(get_raw_dict(args), f, indent=2) + """ + if isinstance(args, argparse.Namespace): + return vars(args) + elif isinstance(args, dict): + return args + elif isinstance(args, SLConfig): + return args._cfg_dict + else: + raise NotImplementedError("Unknown type {}".format(type(args))) + + +def stat_tensors(tensor): + assert tensor.dim() == 1 + tensor_sm = tensor.softmax(0) + entropy = (tensor_sm * torch.log(tensor_sm + 1e-9)).sum() + + return { + "max": tensor.max(), + "min": tensor.min(), + "mean": tensor.mean(), + "var": tensor.var(), + "std": tensor.var() ** 0.5, + "entropy": entropy, + } + + +class NiceRepr: + """Inherit from this class and define ``__nice__`` to "nicely" print your + objects. + + Defines ``__str__`` and ``__repr__`` in terms of ``__nice__`` function + Classes that inherit from :class:`NiceRepr` should redefine ``__nice__``. + If the inheriting class has a ``__len__``, method then the default + ``__nice__`` method will return its length. + + Example: + >>> class Foo(NiceRepr): + ... def __nice__(self): + ... return 'info' + >>> foo = Foo() + >>> assert str(foo) == '' + >>> assert repr(foo).startswith('>> class Bar(NiceRepr): + ... pass + >>> bar = Bar() + >>> import pytest + >>> with pytest.warns(None) as record: + >>> assert 'object at' in str(bar) + >>> assert 'object at' in repr(bar) + + Example: + >>> class Baz(NiceRepr): + ... def __len__(self): + ... return 5 + >>> baz = Baz() + >>> assert str(baz) == '' + """ + + def __nice__(self): + """str: a "nice" summary string describing this module""" + if hasattr(self, "__len__"): + # It is a common pattern for objects to use __len__ in __nice__ + # As a convenience we define a default __nice__ for these objects + return str(len(self)) + else: + # In all other cases force the subclass to overload __nice__ + raise NotImplementedError(f"Define the __nice__ method for {self.__class__!r}") + + def __repr__(self): + """str: the string of the module""" + try: + nice = self.__nice__() + classname = self.__class__.__name__ + return f"<{classname}({nice}) at {hex(id(self))}>" + except NotImplementedError as ex: + warnings.warn(str(ex), category=RuntimeWarning) + return object.__repr__(self) + + def __str__(self): + """str: the string of the module""" + try: + classname = self.__class__.__name__ + nice = self.__nice__() + return f"<{classname}({nice})>" + except NotImplementedError as ex: + warnings.warn(str(ex), category=RuntimeWarning) + return object.__repr__(self) + + +def ensure_rng(rng=None): + """Coerces input into a random number generator. + + If the input is None, then a global random state is returned. + + If the input is a numeric value, then that is used as a seed to construct a + random state. Otherwise the input is returned as-is. + + Adapted from [1]_. + + Args: + rng (int | numpy.random.RandomState | None): + if None, then defaults to the global rng. Otherwise this can be an + integer or a RandomState class + Returns: + (numpy.random.RandomState) : rng - + a numpy random number generator + + References: + .. [1] https://gitlab.kitware.com/computer-vision/kwarray/blob/master/kwarray/util_random.py#L270 # noqa: E501 + """ + + if rng is None: + rng = np.random.mtrand._rand + elif isinstance(rng, int): + rng = np.random.RandomState(rng) + else: + rng = rng + return rng + + +def random_boxes(num=1, scale=1, rng=None): + """Simple version of ``kwimage.Boxes.random`` + + Returns: + Tensor: shape (n, 4) in x1, y1, x2, y2 format. + + References: + https://gitlab.kitware.com/computer-vision/kwimage/blob/master/kwimage/structs/boxes.py#L1390 + + Example: + >>> num = 3 + >>> scale = 512 + >>> rng = 0 + >>> boxes = random_boxes(num, scale, rng) + >>> print(boxes) + tensor([[280.9925, 278.9802, 308.6148, 366.1769], + [216.9113, 330.6978, 224.0446, 456.5878], + [405.3632, 196.3221, 493.3953, 270.7942]]) + """ + rng = ensure_rng(rng) + + tlbr = rng.rand(num, 4).astype(np.float32) + + tl_x = np.minimum(tlbr[:, 0], tlbr[:, 2]) + tl_y = np.minimum(tlbr[:, 1], tlbr[:, 3]) + br_x = np.maximum(tlbr[:, 0], tlbr[:, 2]) + br_y = np.maximum(tlbr[:, 1], tlbr[:, 3]) + + tlbr[:, 0] = tl_x * scale + tlbr[:, 1] = tl_y * scale + tlbr[:, 2] = br_x * scale + tlbr[:, 3] = br_y * scale + + boxes = torch.from_numpy(tlbr) + return boxes + + +class ModelEma(torch.nn.Module): + def __init__(self, model, decay=0.9997, device=None): + super(ModelEma, self).__init__() + # make a copy of the model for accumulating moving average of weights + self.module = deepcopy(model) + self.module.eval() + + # import ipdb; ipdb.set_trace() + + self.decay = decay + self.device = device # perform ema on different device from model if set + if self.device is not None: + self.module.to(device=device) + + def _update(self, model, update_fn): + with torch.no_grad(): + for ema_v, model_v in zip( + self.module.state_dict().values(), model.state_dict().values() + ): + if self.device is not None: + model_v = model_v.to(device=self.device) + ema_v.copy_(update_fn(ema_v, model_v)) + + def update(self, model): + self._update(model, update_fn=lambda e, m: self.decay * e + (1.0 - self.decay) * m) + + def set(self, model): + self._update(model, update_fn=lambda e, m: m) + + +class BestMetricSingle: + def __init__(self, init_res=0.0, better="large") -> None: + self.init_res = init_res + self.best_res = init_res + self.best_ep = -1 + + self.better = better + assert better in ["large", "small"] + + def isbetter(self, new_res, old_res): + if self.better == "large": + return new_res > old_res + if self.better == "small": + return new_res < old_res + + def update(self, new_res, ep): + if self.isbetter(new_res, self.best_res): + self.best_res = new_res + self.best_ep = ep + return True + return False + + def __str__(self) -> str: + return "best_res: {}\t best_ep: {}".format(self.best_res, self.best_ep) + + def __repr__(self) -> str: + return self.__str__() + + def summary(self) -> dict: + return { + "best_res": self.best_res, + "best_ep": self.best_ep, + } + + +class BestMetricHolder: + def __init__(self, init_res=0.0, better="large", use_ema=False) -> None: + self.best_all = BestMetricSingle(init_res, better) + self.use_ema = use_ema + if use_ema: + self.best_ema = BestMetricSingle(init_res, better) + self.best_regular = BestMetricSingle(init_res, better) + + def update(self, new_res, epoch, is_ema=False): + """ + return if the results is the best. + """ + if not self.use_ema: + return self.best_all.update(new_res, epoch) + else: + if is_ema: + self.best_ema.update(new_res, epoch) + return self.best_all.update(new_res, epoch) + else: + self.best_regular.update(new_res, epoch) + return self.best_all.update(new_res, epoch) + + def summary(self): + if not self.use_ema: + return self.best_all.summary() + + res = {} + res.update({f"all_{k}": v for k, v in self.best_all.summary().items()}) + res.update({f"regular_{k}": v for k, v in self.best_regular.summary().items()}) + res.update({f"ema_{k}": v for k, v in self.best_ema.summary().items()}) + return res + + def __repr__(self) -> str: + return json.dumps(self.summary(), indent=2) + + def __str__(self) -> str: + return self.__repr__() + + +def targets_to(targets: List[Dict[str, Any]], device): + """Moves the target dicts to the given device.""" + excluded_keys = [ + "questionId", + "tokens_positive", + "strings_positive", + "tokens", + "dataset_name", + "sentence_id", + "original_img_id", + "nb_eval", + "task_id", + "original_id", + "token_span", + "caption", + "dataset_type", + ] + return [ + {k: v.to(device) if k not in excluded_keys else v for k, v in t.items()} for t in targets + ] + + +def get_phrases_from_posmap( + posmap: torch.BoolTensor, tokenized: Dict, tokenizer: AutoTokenizer +): + assert isinstance(posmap, torch.Tensor), "posmap must be torch.Tensor" + if posmap.dim() == 1: + non_zero_idx = posmap.nonzero(as_tuple=True)[0].tolist() + token_ids = [tokenized["input_ids"][i] for i in non_zero_idx] + return tokenizer.decode(token_ids) + else: + raise NotImplementedError("posmap must be 1-dim") + diff --git a/simple_syrup/third_party/sam_hq_runtime/__init__.py b/simple_syrup/third_party/sam_hq_runtime/__init__.py new file mode 100644 index 0000000..94bb338 --- /dev/null +++ b/simple_syrup/third_party/sam_hq_runtime/__init__.py @@ -0,0 +1,2 @@ +"""Vendored SAM-HQ runtime used by SimpleSyrup-owned SAM loaders.""" + diff --git a/simple_syrup/third_party/sam_hq_runtime/automatic.py b/simple_syrup/third_party/sam_hq_runtime/automatic.py new file mode 100644 index 0000000..b563d36 --- /dev/null +++ b/simple_syrup/third_party/sam_hq_runtime/automatic.py @@ -0,0 +1,115 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. + +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +import numpy as np + +from typing import List, Optional + +from segment_anything import SamAutomaticMaskGenerator +from segment_anything.utils.amg import build_all_layer_point_grids +from .predictor import SamPredictorHQ + + +class SamAutomaticMaskGeneratorHQ(SamAutomaticMaskGenerator): + def __init__( + self, + model: SamPredictorHQ, + points_per_side: Optional[int] = 32, + points_per_batch: int = 64, + pred_iou_thresh: float = 0.88, + stability_score_thresh: float = 0.95, + stability_score_offset: float = 1.0, + box_nms_thresh: float = 0.7, + crop_n_layers: int = 0, + crop_nms_thresh: float = 0.7, + crop_overlap_ratio: float = 512 / 1500, + crop_n_points_downscale_factor: int = 1, + point_grids: Optional[List[np.ndarray]] = None, + min_mask_region_area: int = 0, + output_mode: str = "binary_mask", + ) -> None: + """ + Using a SAM model, generates masks for the entire image. + Generates a grid of point prompts over the image, then filters + low quality and duplicate masks. The default settings are chosen + for SAM with a ViT-H backbone. + + Arguments: + model (Sam): The SAM model to use for mask prediction. + points_per_side (int or None): The number of points to be sampled + along one side of the image. The total number of points is + points_per_side**2. If None, 'point_grids' must provide explicit + point sampling. + points_per_batch (int): Sets the number of points run simultaneously + by the model. Higher numbers may be faster but use more GPU memory. + pred_iou_thresh (float): A filtering threshold in [0,1], using the + model's predicted mask quality. + stability_score_thresh (float): A filtering threshold in [0,1], using + the stability of the mask under changes to the cutoff used to binarize + the model's mask predictions. + stability_score_offset (float): The amount to shift the cutoff when + calculated the stability score. + box_nms_thresh (float): The box IoU cutoff used by non-maximal + suppression to filter duplicate masks. + crop_n_layers (int): If >0, mask prediction will be run again on + crops of the image. Sets the number of layers to run, where each + layer has 2**i_layer number of image crops. + crop_nms_thresh (float): The box IoU cutoff used by non-maximal + suppression to filter duplicate masks between different crops. + crop_overlap_ratio (float): Sets the degree to which crops overlap. + In the first crop layer, crops will overlap by this fraction of + the image length. Later layers with more crops scale down this overlap. + crop_n_points_downscale_factor (int): The number of points-per-side + sampled in layer n is scaled down by crop_n_points_downscale_factor**n. + point_grids (list(np.ndarray) or None): A list over explicit grids + of points used for sampling, normalized to [0,1]. The nth grid in the + list is used in the nth crop layer. Exclusive with points_per_side. + min_mask_region_area (int): If >0, postprocessing will be applied + to remove disconnected regions and holes in masks with area smaller + than min_mask_region_area. Requires opencv. + output_mode (str): The form masks are returned in. Can be 'binary_mask', + 'uncompressed_rle', or 'coco_rle'. 'coco_rle' requires pycocotools. + For large resolutions, 'binary_mask' may consume large amounts of + memory. + """ + + assert (points_per_side is None) != ( + point_grids is None + ), "Exactly one of points_per_side or point_grid must be provided." + if points_per_side is not None: + self.point_grids = build_all_layer_point_grids( + points_per_side, + crop_n_layers, + crop_n_points_downscale_factor, + ) + elif point_grids is not None: + self.point_grids = point_grids + else: + raise ValueError("Can't have both points_per_side and point_grid be None.") + + assert output_mode in [ + "binary_mask", + "uncompressed_rle", + "coco_rle", + ], f"Unknown output_mode {output_mode}." + if output_mode == "coco_rle": + from pycocotools import mask as mask_utils # type: ignore # noqa: F401 + + if min_mask_region_area > 0: + import cv2 # type: ignore # noqa: F401 + + self.predictor = model + self.points_per_batch = points_per_batch + self.pred_iou_thresh = pred_iou_thresh + self.stability_score_thresh = stability_score_thresh + self.stability_score_offset = stability_score_offset + self.box_nms_thresh = box_nms_thresh + self.crop_n_layers = crop_n_layers + self.crop_nms_thresh = crop_nms_thresh + self.crop_overlap_ratio = crop_overlap_ratio + self.crop_n_points_downscale_factor = crop_n_points_downscale_factor + self.min_mask_region_area = min_mask_region_area + self.output_mode = output_mode diff --git a/simple_syrup/third_party/sam_hq_runtime/build_sam_hq.py b/simple_syrup/third_party/sam_hq_runtime/build_sam_hq.py new file mode 100644 index 0000000..6882d3b --- /dev/null +++ b/simple_syrup/third_party/sam_hq_runtime/build_sam_hq.py @@ -0,0 +1,165 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. + +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +import torch + +from functools import partial + +from .modeling.mask_decoder_hq import MaskDecoderHQ +from .modeling.image_encoder import ImageEncoderViTHQ +from .modeling.tiny_vit import TinyViT +from segment_anything.modeling import PromptEncoder, Sam, TwoWayTransformer, MaskDecoder +from segment_anything import build_sam_vit_h, build_sam_vit_l, build_sam_vit_b + + +def build_sam_hq_vit_h(checkpoint=None): + return _build_sam_hq( + encoder_embed_dim=1280, + encoder_depth=32, + encoder_num_heads=16, + encoder_global_attn_indexes=[7, 15, 23, 31], + checkpoint=checkpoint, + ) + + +def build_sam_hq_vit_l(checkpoint=None): + return _build_sam_hq( + encoder_embed_dim=1024, + encoder_depth=24, + encoder_num_heads=16, + encoder_global_attn_indexes=[5, 11, 17, 23], + checkpoint=checkpoint, + ) + + +def build_sam_hq_vit_b(checkpoint=None): + return _build_sam_hq( + encoder_embed_dim=768, + encoder_depth=12, + encoder_num_heads=12, + encoder_global_attn_indexes=[2, 5, 8, 11], + checkpoint=checkpoint, + ) + + +def build_mobile_sam(checkpoint=None): + return _build_mobile_sam(checkpoint) + + +sam_model_registry = { + "sam_vit_h": build_sam_vit_h, + "sam_vit_l": build_sam_vit_l, + "sam_vit_b": build_sam_vit_b, + "sam_hq_vit_h": build_sam_hq_vit_h, + "sam_hq_vit_l": build_sam_hq_vit_l, + "sam_hq_vit_b": build_sam_hq_vit_b, + "mobile_sam": build_mobile_sam, +} + + +def _load_sam_checkpoint(sam: Sam, checkpoint=None): + sam.eval() + if checkpoint is not None: + with open(checkpoint, "rb") as f: + state_dict = torch.load(f, map_location="cpu") + sam.load_state_dict(state_dict, strict=False) + for _, p in sam.named_parameters(): + p.requires_grad = False + return sam + +def _build_sam_hq( + encoder_embed_dim, + encoder_depth, + encoder_num_heads, + encoder_global_attn_indexes, + checkpoint=None, +): + prompt_embed_dim = 256 + image_size = 1024 + vit_patch_size = 16 + image_embedding_size = image_size // vit_patch_size + sam = Sam( + image_encoder=ImageEncoderViTHQ( + depth=encoder_depth, + embed_dim=encoder_embed_dim, + img_size=image_size, + mlp_ratio=4, + norm_layer=partial(torch.nn.LayerNorm, eps=1e-6), + num_heads=encoder_num_heads, + patch_size=vit_patch_size, + qkv_bias=True, + use_rel_pos=True, + global_attn_indexes=encoder_global_attn_indexes, + window_size=14, + out_chans=prompt_embed_dim, + ), + prompt_encoder=PromptEncoder( + embed_dim=prompt_embed_dim, + image_embedding_size=(image_embedding_size, image_embedding_size), + input_image_size=(image_size, image_size), + mask_in_chans=16, + ), + mask_decoder=MaskDecoderHQ( + num_multimask_outputs=3, + transformer=TwoWayTransformer( + depth=2, + embedding_dim=prompt_embed_dim, + mlp_dim=2048, + num_heads=8, + ), + transformer_dim=prompt_embed_dim, + iou_head_depth=3, + iou_head_hidden_dim=256, + vit_dim=encoder_embed_dim, + ), + pixel_mean=[123.675, 116.28, 103.53], + pixel_std=[58.395, 57.12, 57.375], + ) + return _load_sam_checkpoint(sam, checkpoint) + + +def _build_mobile_sam(checkpoint=None): + prompt_embed_dim = 256 + image_size = 1024 + vit_patch_size = 16 + image_embedding_size = image_size // vit_patch_size + mobile_sam = Sam( + image_encoder=TinyViT( + img_size=1024, in_chans=3, num_classes=1000, + embed_dims=[64, 128, 160, 320], + depths=[2, 2, 6, 2], + num_heads=[2, 4, 5, 10], + window_sizes=[7, 7, 14, 7], + mlp_ratio=4., + drop_rate=0., + drop_path_rate=0.0, + use_checkpoint=False, + mbconv_expand_ratio=4.0, + local_conv_size=3, + layer_lr_decay=0.8 + ), + prompt_encoder=PromptEncoder( + embed_dim=prompt_embed_dim, + image_embedding_size=(image_embedding_size, image_embedding_size), + input_image_size=(image_size, image_size), + mask_in_chans=16, + ), + mask_decoder=MaskDecoder( + num_multimask_outputs=3, + transformer=TwoWayTransformer( + depth=2, + embedding_dim=prompt_embed_dim, + mlp_dim=2048, + num_heads=8, + ), + transformer_dim=prompt_embed_dim, + iou_head_depth=3, + iou_head_hidden_dim=256, + ), + pixel_mean=[123.675, 116.28, 103.53], + pixel_std=[58.395, 57.12, 57.375], + ) + return _load_sam_checkpoint(mobile_sam, checkpoint) diff --git a/simple_syrup/third_party/sam_hq_runtime/modeling/__init__.py b/simple_syrup/third_party/sam_hq_runtime/modeling/__init__.py new file mode 100644 index 0000000..90c03fc --- /dev/null +++ b/simple_syrup/third_party/sam_hq_runtime/modeling/__init__.py @@ -0,0 +1,2 @@ +"""SAM-HQ model components vendored for SimpleSyrup runtime loading.""" + diff --git a/simple_syrup/third_party/sam_hq_runtime/modeling/image_encoder.py b/simple_syrup/third_party/sam_hq_runtime/modeling/image_encoder.py new file mode 100644 index 0000000..8933bc0 --- /dev/null +++ b/simple_syrup/third_party/sam_hq_runtime/modeling/image_encoder.py @@ -0,0 +1,20 @@ +import torch +from segment_anything.modeling import ImageEncoderViT + +# This class and its supporting functions below lightly adapted from the ViTDet backbone available at: https://github.com/facebookresearch/detectron2/blob/main/detectron2/modeling/backbone/vit.py # noqa +class ImageEncoderViTHQ(ImageEncoderViT): + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.patch_embed(x) + if self.pos_embed is not None: + x = x + self.pos_embed + + interm_embeddings=[] + for blk in self.blocks: + x = blk(x) + if blk.window_size == 0: + interm_embeddings.append(x) + + x = self.neck(x.permute(0, 3, 1, 2)) + + return x, interm_embeddings \ No newline at end of file diff --git a/simple_syrup/third_party/sam_hq_runtime/modeling/mask_decoder_hq.py b/simple_syrup/third_party/sam_hq_runtime/modeling/mask_decoder_hq.py new file mode 100644 index 0000000..244133c --- /dev/null +++ b/simple_syrup/third_party/sam_hq_runtime/modeling/mask_decoder_hq.py @@ -0,0 +1,236 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# Modified by HQ-SAM team +# All rights reserved. + +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +import torch +from torch import nn +from torch.nn import functional as F + +from typing import List, Tuple, Type + +from segment_anything.modeling.common import LayerNorm2d + + +class MaskDecoderHQ(nn.Module): + def __init__( + self, + *, + transformer_dim: int, + transformer: nn.Module, + num_multimask_outputs: int = 3, + activation: Type[nn.Module] = nn.GELU, + iou_head_depth: int = 3, + iou_head_hidden_dim: int = 256, + vit_dim: int = 1024, + ) -> None: + """ + Predicts masks given an image and prompt embeddings, using a + transformer architecture. + + Arguments: + transformer_dim (int): the channel dimension of the transformer + transformer (nn.Module): the transformer used to predict masks + num_multimask_outputs (int): the number of masks to predict + when disambiguating masks + activation (nn.Module): the type of activation to use when + upscaling masks + iou_head_depth (int): the depth of the MLP used to predict + mask quality + iou_head_hidden_dim (int): the hidden dimension of the MLP + used to predict mask quality + """ + super().__init__() + self.transformer_dim = transformer_dim + self.transformer = transformer + + self.num_multimask_outputs = num_multimask_outputs + + self.iou_token = nn.Embedding(1, transformer_dim) + self.num_mask_tokens = num_multimask_outputs + 1 + self.mask_tokens = nn.Embedding(self.num_mask_tokens, transformer_dim) + + self.output_upscaling = nn.Sequential( + nn.ConvTranspose2d(transformer_dim, transformer_dim // 4, kernel_size=2, stride=2), + LayerNorm2d(transformer_dim // 4), + activation(), + nn.ConvTranspose2d(transformer_dim // 4, transformer_dim // 8, kernel_size=2, stride=2), + activation(), + ) + self.output_hypernetworks_mlps = nn.ModuleList( + [ + MLP(transformer_dim, transformer_dim, transformer_dim // 8, 3) + for i in range(self.num_mask_tokens) + ] + ) + + self.iou_prediction_head = MLP( + transformer_dim, iou_head_hidden_dim, self.num_mask_tokens, iou_head_depth + ) + + # HQ-SAM parameters + self.hf_token = nn.Embedding(1, transformer_dim) # HQ-Ouptput-Token + self.hf_mlp = MLP(transformer_dim, transformer_dim, transformer_dim // 8, 3) # corresponding new MLP layer for HQ-Ouptput-Token + self.num_mask_tokens = self.num_mask_tokens + 1 + + # three conv fusion layers for obtaining HQ-Feature + self.compress_vit_feat = nn.Sequential( + nn.ConvTranspose2d(vit_dim, transformer_dim, kernel_size=2, stride=2), + LayerNorm2d(transformer_dim), + nn.GELU(), + nn.ConvTranspose2d(transformer_dim, transformer_dim // 8, kernel_size=2, stride=2)) + + self.embedding_encoder = nn.Sequential( + nn.ConvTranspose2d(transformer_dim, transformer_dim // 4, kernel_size=2, stride=2), + LayerNorm2d(transformer_dim // 4), + nn.GELU(), + nn.ConvTranspose2d(transformer_dim // 4, transformer_dim // 8, kernel_size=2, stride=2), + ) + self.embedding_maskfeature = nn.Sequential( + nn.Conv2d(transformer_dim // 8, transformer_dim // 4, 3, 1, 1), + LayerNorm2d(transformer_dim // 4), + nn.GELU(), + nn.Conv2d(transformer_dim // 4, transformer_dim // 8, 3, 1, 1)) + + + + def forward( + self, + image_embeddings: torch.Tensor, + image_pe: torch.Tensor, + sparse_prompt_embeddings: torch.Tensor, + dense_prompt_embeddings: torch.Tensor, + multimask_output: bool, + hq_token_only: bool = False, + interm_embeddings: torch.Tensor = None, + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Predict masks given image and prompt embeddings. + + Arguments: + image_embeddings (torch.Tensor): the embeddings from the ViT image encoder + image_pe (torch.Tensor): positional encoding with the shape of image_embeddings + sparse_prompt_embeddings (torch.Tensor): the embeddings of the points and boxes + dense_prompt_embeddings (torch.Tensor): the embeddings of the mask inputs + multimask_output (bool): Whether to return multiple masks or a single + mask. + + Returns: + torch.Tensor: batched predicted masks + torch.Tensor: batched predictions of mask quality + """ + vit_features = interm_embeddings[0].permute(0, 3, 1, 2) # early-layer ViT feature, after 1st global attention block in ViT + hq_features = self.embedding_encoder(image_embeddings) + self.compress_vit_feat(vit_features) + + masks, iou_pred, masks_hq = self.predict_masks( + image_embeddings=image_embeddings, + image_pe=image_pe, + sparse_prompt_embeddings=sparse_prompt_embeddings, + dense_prompt_embeddings=dense_prompt_embeddings, + hq_features=hq_features, + ) + + # Select the correct mask or masks for output + # if multimask_output: + # # mask with highest score + # mask_slice = slice(1,self.num_mask_tokens-1) + # iou_pred = iou_pred[:, mask_slice] + # iou_pred, max_iou_idx = torch.max(iou_pred,dim=1) + # iou_pred = iou_pred.unsqueeze(1) + # masks_multi = masks[:, mask_slice, :, :] + # masks_sam = masks_multi[torch.arange(masks_multi.size(0)),max_iou_idx].unsqueeze(1) + # else: + # # single mask output, default + # mask_slice = slice(0, 1) + # iou_pred = iou_pred[:,mask_slice] + # masks_sam = masks[:,mask_slice] + if multimask_output: + mask_slice = slice(1, None) + else: + mask_slice = slice(0, 1) + masks_sam = masks[:, mask_slice, :, :] + iou_pred = iou_pred[:, mask_slice] + if hq_token_only: + masks = masks_hq + else: + masks = masks_sam + masks_hq + # Prepare output + return masks, iou_pred + + def predict_masks( + self, + image_embeddings: torch.Tensor, + image_pe: torch.Tensor, + sparse_prompt_embeddings: torch.Tensor, + dense_prompt_embeddings: torch.Tensor, + hq_features: torch.Tensor, + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Predicts masks. See 'forward' for more details.""" + # Concatenate output tokens + output_tokens = torch.cat([self.iou_token.weight, self.mask_tokens.weight, self.hf_token.weight], dim=0) + output_tokens = output_tokens.unsqueeze(0).expand(sparse_prompt_embeddings.size(0), -1, -1) + tokens = torch.cat((output_tokens, sparse_prompt_embeddings), dim=1) + + # Expand per-image data in batch direction to be per-mask + src = torch.repeat_interleave(image_embeddings, tokens.shape[0], dim=0) + src = src + dense_prompt_embeddings + pos_src = torch.repeat_interleave(image_pe, tokens.shape[0], dim=0) + b, c, h, w = src.shape + + # Run the transformer + hs, src = self.transformer(src, pos_src, tokens) + iou_token_out = hs[:, 0, :] + mask_tokens_out = hs[:, 1 : (1 + self.num_mask_tokens), :] + + # Upscale mask embeddings and predict masks using the mask tokens + src = src.transpose(1, 2).view(b, c, h, w) + + upscaled_embedding_sam = self.output_upscaling(src) + upscaled_embedding_hq = self.embedding_maskfeature(upscaled_embedding_sam) + hq_features.repeat(b,1,1,1) + + hyper_in_list: List[torch.Tensor] = [] + for i in range(self.num_mask_tokens): + if i < self.num_mask_tokens - 1: + hyper_in_list.append(self.output_hypernetworks_mlps[i](mask_tokens_out[:, i, :])) + else: + hyper_in_list.append(self.hf_mlp(mask_tokens_out[:, i, :])) + + hyper_in = torch.stack(hyper_in_list, dim=1) + b, c, h, w = upscaled_embedding_sam.shape + + masks_sam = (hyper_in[:,:self.num_mask_tokens-1] @ upscaled_embedding_sam.view(b, c, h * w)).view(b, -1, h, w) + masks_sam_hq = (hyper_in[:,self.num_mask_tokens-1:] @ upscaled_embedding_hq.view(b, c, h * w)).view(b, -1, h, w) + # masks = torch.cat([masks_sam,masks_sam_hq],dim=1) + # Generate mask quality predictions + iou_pred = self.iou_prediction_head(iou_token_out) + + return masks_sam, iou_pred, masks_sam_hq + + +# Lightly adapted from +# https://github.com/facebookresearch/MaskFormer/blob/main/mask_former/modeling/transformer/transformer_predictor.py # noqa +class MLP(nn.Module): + def __init__( + self, + input_dim: int, + hidden_dim: int, + output_dim: int, + num_layers: int, + sigmoid_output: bool = False, + ) -> None: + super().__init__() + self.num_layers = num_layers + h = [hidden_dim] * (num_layers - 1) + self.layers = nn.ModuleList( + nn.Linear(n, k) for n, k in zip([input_dim] + h, h + [output_dim]) + ) + self.sigmoid_output = sigmoid_output + + def forward(self, x): + for i, layer in enumerate(self.layers): + x = F.relu(layer(x)) if i < self.num_layers - 1 else layer(x) + if self.sigmoid_output: + x = F.sigmoid(x) + return x diff --git a/simple_syrup/third_party/sam_hq_runtime/modeling/tiny_vit.py b/simple_syrup/third_party/sam_hq_runtime/modeling/tiny_vit.py new file mode 100644 index 0000000..fd8bd73 --- /dev/null +++ b/simple_syrup/third_party/sam_hq_runtime/modeling/tiny_vit.py @@ -0,0 +1,617 @@ +# -------------------------------------------------------- +# TinyViT Model Architecture +# Copyright (c) 2022 Microsoft +# Adapted from LeViT and Swin Transformer +# LeViT: (https://github.com/facebookresearch/levit) +# Swin: (https://github.com/microsoft/swin-transformer) +# Build the TinyViT Model +# -------------------------------------------------------- + +import itertools +import torch +import torch.nn as nn +import torch.nn.functional as F +import torch.utils.checkpoint as checkpoint +from timm.models.layers import DropPath as TimmDropPath,\ + to_2tuple, trunc_normal_ +from timm.models import register_model +from typing import Tuple + + +class Conv2d_BN(torch.nn.Sequential): + def __init__(self, a, b, ks=1, stride=1, pad=0, dilation=1, + groups=1, bn_weight_init=1): + super().__init__() + self.add_module('c', torch.nn.Conv2d( + a, b, ks, stride, pad, dilation, groups, bias=False)) + bn = torch.nn.BatchNorm2d(b) + torch.nn.init.constant_(bn.weight, bn_weight_init) + torch.nn.init.constant_(bn.bias, 0) + self.add_module('bn', bn) + + @torch.no_grad() + def fuse(self): + c, bn = self._modules.values() + w = bn.weight / (bn.running_var + bn.eps)**0.5 + w = c.weight * w[:, None, None, None] + b = bn.bias - bn.running_mean * bn.weight / \ + (bn.running_var + bn.eps)**0.5 + m = torch.nn.Conv2d(w.size(1) * self.c.groups, w.size( + 0), w.shape[2:], stride=self.c.stride, padding=self.c.padding, dilation=self.c.dilation, groups=self.c.groups) + m.weight.data.copy_(w) + m.bias.data.copy_(b) + return m + + +class DropPath(TimmDropPath): + def __init__(self, drop_prob=None): + super().__init__(drop_prob=drop_prob) + self.drop_prob = drop_prob + + def __repr__(self): + msg = super().__repr__() + msg += f'(drop_prob={self.drop_prob})' + return msg + + +class PatchEmbed(nn.Module): + def __init__(self, in_chans, embed_dim, resolution, activation): + super().__init__() + img_size: Tuple[int, int] = to_2tuple(resolution) + self.patches_resolution = (img_size[0] // 4, img_size[1] // 4) + self.num_patches = self.patches_resolution[0] * \ + self.patches_resolution[1] + self.in_chans = in_chans + self.embed_dim = embed_dim + n = embed_dim + self.seq = nn.Sequential( + Conv2d_BN(in_chans, n // 2, 3, 2, 1), + activation(), + Conv2d_BN(n // 2, n, 3, 2, 1), + ) + + def forward(self, x): + return self.seq(x) + + +class MBConv(nn.Module): + def __init__(self, in_chans, out_chans, expand_ratio, + activation, drop_path): + super().__init__() + self.in_chans = in_chans + self.hidden_chans = int(in_chans * expand_ratio) + self.out_chans = out_chans + + self.conv1 = Conv2d_BN(in_chans, self.hidden_chans, ks=1) + self.act1 = activation() + + self.conv2 = Conv2d_BN(self.hidden_chans, self.hidden_chans, + ks=3, stride=1, pad=1, groups=self.hidden_chans) + self.act2 = activation() + + self.conv3 = Conv2d_BN( + self.hidden_chans, out_chans, ks=1, bn_weight_init=0.0) + self.act3 = activation() + + self.drop_path = DropPath( + drop_path) if drop_path > 0. else nn.Identity() + + def forward(self, x): + shortcut = x + + x = self.conv1(x) + x = self.act1(x) + + x = self.conv2(x) + x = self.act2(x) + + x = self.conv3(x) + + x = self.drop_path(x) + + x += shortcut + x = self.act3(x) + + return x + + +class PatchMerging(nn.Module): + def __init__(self, input_resolution, dim, out_dim, activation): + super().__init__() + + self.input_resolution = input_resolution + self.dim = dim + self.out_dim = out_dim + self.act = activation() + self.conv1 = Conv2d_BN(dim, out_dim, 1, 1, 0) + stride_c=2 + if(out_dim==320 or out_dim==448 or out_dim==576): + stride_c=1 + self.conv2 = Conv2d_BN(out_dim, out_dim, 3, stride_c, 1, groups=out_dim) + self.conv3 = Conv2d_BN(out_dim, out_dim, 1, 1, 0) + + def forward(self, x): + if x.ndim == 3: + H, W = self.input_resolution + B = len(x) + # (B, C, H, W) + x = x.view(B, H, W, -1).permute(0, 3, 1, 2) + + x = self.conv1(x) + x = self.act(x) + + x = self.conv2(x) + x = self.act(x) + x = self.conv3(x) + x = x.flatten(2).transpose(1, 2) + return x + + +class ConvLayer(nn.Module): + def __init__(self, dim, input_resolution, depth, + activation, + drop_path=0., downsample=None, use_checkpoint=False, + out_dim=None, + conv_expand_ratio=4., + ): + + super().__init__() + self.dim = dim + self.input_resolution = input_resolution + self.depth = depth + self.use_checkpoint = use_checkpoint + + # build blocks + self.blocks = nn.ModuleList([ + MBConv(dim, dim, conv_expand_ratio, activation, + drop_path[i] if isinstance(drop_path, list) else drop_path, + ) + for i in range(depth)]) + + # patch merging layer + if downsample is not None: + self.downsample = downsample( + input_resolution, dim=dim, out_dim=out_dim, activation=activation) + else: + self.downsample = None + + def forward(self, x): + for blk in self.blocks: + if self.use_checkpoint: + x = checkpoint.checkpoint(blk, x) + else: + x = blk(x) + if self.downsample is not None: + x = self.downsample(x) + return x + + +class Mlp(nn.Module): + def __init__(self, in_features, hidden_features=None, + out_features=None, act_layer=nn.GELU, drop=0.): + super().__init__() + out_features = out_features or in_features + hidden_features = hidden_features or in_features + self.norm = nn.LayerNorm(in_features) + self.fc1 = nn.Linear(in_features, hidden_features) + self.fc2 = nn.Linear(hidden_features, out_features) + self.act = act_layer() + self.drop = nn.Dropout(drop) + + def forward(self, x): + x = self.norm(x) + + x = self.fc1(x) + x = self.act(x) + x = self.drop(x) + x = self.fc2(x) + x = self.drop(x) + return x + + +class Attention(torch.nn.Module): + def __init__(self, dim, key_dim, num_heads=8, + attn_ratio=4, + resolution=(14, 14), + ): + super().__init__() + # (h, w) + assert isinstance(resolution, tuple) and len(resolution) == 2 + self.num_heads = num_heads + self.scale = key_dim ** -0.5 + self.key_dim = key_dim + self.nh_kd = nh_kd = key_dim * num_heads + self.d = int(attn_ratio * key_dim) + self.dh = int(attn_ratio * key_dim) * num_heads + self.attn_ratio = attn_ratio + h = self.dh + nh_kd * 2 + + self.norm = nn.LayerNorm(dim) + self.qkv = nn.Linear(dim, h) + self.proj = nn.Linear(self.dh, dim) + + points = list(itertools.product( + range(resolution[0]), range(resolution[1]))) + N = len(points) + attention_offsets = {} + idxs = [] + for p1 in points: + for p2 in points: + offset = (abs(p1[0] - p2[0]), abs(p1[1] - p2[1])) + if offset not in attention_offsets: + attention_offsets[offset] = len(attention_offsets) + idxs.append(attention_offsets[offset]) + self.attention_biases = torch.nn.Parameter( + torch.zeros(num_heads, len(attention_offsets))) + self.register_buffer('attention_bias_idxs', + torch.LongTensor(idxs).view(N, N), + persistent=False) + + @torch.no_grad() + def train(self, mode=True): + super().train(mode) + if mode and hasattr(self, 'ab'): + del self.ab + else: + self.ab = self.attention_biases[:, self.attention_bias_idxs] + + def forward(self, x): # x (B,N,C) + B, N, _ = x.shape + + # Normalization + x = self.norm(x) + + qkv = self.qkv(x) + # (B, N, num_heads, d) + q, k, v = qkv.view(B, N, self.num_heads, - + 1).split([self.key_dim, self.key_dim, self.d], dim=3) + # (B, num_heads, N, d) + q = q.permute(0, 2, 1, 3) + k = k.permute(0, 2, 1, 3) + v = v.permute(0, 2, 1, 3) + + attn = ( + (q @ k.transpose(-2, -1)) * self.scale + + + (self.attention_biases[:, self.attention_bias_idxs] + if self.training else self.ab) + ) + attn = attn.softmax(dim=-1) + x = (attn @ v).transpose(1, 2).reshape(B, N, self.dh) + x = self.proj(x) + return x + + +class TinyViTBlock(nn.Module): + r""" TinyViT Block. + + Args: + dim (int): Number of input channels. + input_resolution (tuple[int, int]): Input resulotion. + num_heads (int): Number of attention heads. + window_size (int): Window size. + mlp_ratio (float): Ratio of mlp hidden dim to embedding dim. + drop (float, optional): Dropout rate. Default: 0.0 + drop_path (float, optional): Stochastic depth rate. Default: 0.0 + local_conv_size (int): the kernel size of the convolution between + Attention and MLP. Default: 3 + activation: the activation function. Default: nn.GELU + """ + + def __init__(self, dim, input_resolution, num_heads, window_size=7, + mlp_ratio=4., drop=0., drop_path=0., + local_conv_size=3, + activation=nn.GELU, + ): + super().__init__() + self.dim = dim + self.input_resolution = input_resolution + self.num_heads = num_heads + assert window_size > 0, 'window_size must be greater than 0' + self.window_size = window_size + self.mlp_ratio = mlp_ratio + + self.drop_path = DropPath( + drop_path) if drop_path > 0. else nn.Identity() + + assert dim % num_heads == 0, 'dim must be divisible by num_heads' + head_dim = dim // num_heads + + window_resolution = (window_size, window_size) + self.attn = Attention(dim, head_dim, num_heads, + attn_ratio=1, resolution=window_resolution) + + mlp_hidden_dim = int(dim * mlp_ratio) + mlp_activation = activation + self.mlp = Mlp(in_features=dim, hidden_features=mlp_hidden_dim, + act_layer=mlp_activation, drop=drop) + + pad = local_conv_size // 2 + self.local_conv = Conv2d_BN( + dim, dim, ks=local_conv_size, stride=1, pad=pad, groups=dim) + + def forward(self, x): + H, W = self.input_resolution + B, L, C = x.shape + assert L == H * W, "input feature has wrong size" + res_x = x + if H == self.window_size and W == self.window_size: + x = self.attn(x) + else: + x = x.view(B, H, W, C) + pad_b = (self.window_size - H % + self.window_size) % self.window_size + pad_r = (self.window_size - W % + self.window_size) % self.window_size + padding = pad_b > 0 or pad_r > 0 + + if padding: + x = F.pad(x, (0, 0, 0, pad_r, 0, pad_b)) + + pH, pW = H + pad_b, W + pad_r + nH = pH // self.window_size + nW = pW // self.window_size + # window partition + x = x.view(B, nH, self.window_size, nW, self.window_size, C).transpose(2, 3).reshape( + B * nH * nW, self.window_size * self.window_size, C) + x = self.attn(x) + # window reverse + x = x.view(B, nH, nW, self.window_size, self.window_size, + C).transpose(2, 3).reshape(B, pH, pW, C) + + if padding: + x = x[:, :H, :W].contiguous() + + x = x.view(B, L, C) + + x = res_x + self.drop_path(x) + + x = x.transpose(1, 2).reshape(B, C, H, W) + x = self.local_conv(x) + x = x.view(B, C, L).transpose(1, 2) + + x = x + self.drop_path(self.mlp(x)) + return x + + def extra_repr(self) -> str: + return f"dim={self.dim}, input_resolution={self.input_resolution}, num_heads={self.num_heads}, " \ + f"window_size={self.window_size}, mlp_ratio={self.mlp_ratio}" + + +class BasicLayer(nn.Module): + """ A basic TinyViT layer for one stage. + + Args: + dim (int): Number of input channels. + input_resolution (tuple[int]): Input resolution. + depth (int): Number of blocks. + num_heads (int): Number of attention heads. + window_size (int): Local window size. + mlp_ratio (float): Ratio of mlp hidden dim to embedding dim. + drop (float, optional): Dropout rate. Default: 0.0 + drop_path (float | tuple[float], optional): Stochastic depth rate. Default: 0.0 + downsample (nn.Module | None, optional): Downsample layer at the end of the layer. Default: None + use_checkpoint (bool): Whether to use checkpointing to save memory. Default: False. + local_conv_size: the kernel size of the depthwise convolution between attention and MLP. Default: 3 + activation: the activation function. Default: nn.GELU + out_dim: the output dimension of the layer. Default: dim + """ + + def __init__(self, dim, input_resolution, depth, num_heads, window_size, + mlp_ratio=4., drop=0., + drop_path=0., downsample=None, use_checkpoint=False, + local_conv_size=3, + activation=nn.GELU, + out_dim=None, + ): + + super().__init__() + self.dim = dim + self.input_resolution = input_resolution + self.depth = depth + self.use_checkpoint = use_checkpoint + + # build blocks + self.blocks = nn.ModuleList([ + TinyViTBlock(dim=dim, input_resolution=input_resolution, + num_heads=num_heads, window_size=window_size, + mlp_ratio=mlp_ratio, + drop=drop, + drop_path=drop_path[i] if isinstance( + drop_path, list) else drop_path, + local_conv_size=local_conv_size, + activation=activation, + ) + for i in range(depth)]) + + # patch merging layer + if downsample is not None: + self.downsample = downsample( + input_resolution, dim=dim, out_dim=out_dim, activation=activation) + else: + self.downsample = None + + def forward(self, x): + for blk in self.blocks: + if self.use_checkpoint: + x = checkpoint.checkpoint(blk, x) + else: + x = blk(x) + if self.downsample is not None: + x = self.downsample(x) + return x + + def extra_repr(self) -> str: + return f"dim={self.dim}, input_resolution={self.input_resolution}, depth={self.depth}" + +class LayerNorm2d(nn.Module): + def __init__(self, num_channels: int, eps: float = 1e-6) -> None: + super().__init__() + self.weight = nn.Parameter(torch.ones(num_channels)) + self.bias = nn.Parameter(torch.zeros(num_channels)) + self.eps = eps + + def forward(self, x: torch.Tensor) -> torch.Tensor: + u = x.mean(1, keepdim=True) + s = (x - u).pow(2).mean(1, keepdim=True) + x = (x - u) / torch.sqrt(s + self.eps) + x = self.weight[:, None, None] * x + self.bias[:, None, None] + return x +class TinyViT(nn.Module): + def __init__(self, img_size=224, in_chans=3, num_classes=1000, + embed_dims=[96, 192, 384, 768], depths=[2, 2, 6, 2], + num_heads=[3, 6, 12, 24], + window_sizes=[7, 7, 14, 7], + mlp_ratio=4., + drop_rate=0., + drop_path_rate=0.1, + use_checkpoint=False, + mbconv_expand_ratio=4.0, + local_conv_size=3, + layer_lr_decay=1.0, + ): + super().__init__() + self.img_size=img_size + self.num_classes = num_classes + self.depths = depths + self.num_layers = len(depths) + self.mlp_ratio = mlp_ratio + + activation = nn.GELU + + self.patch_embed = PatchEmbed(in_chans=in_chans, + embed_dim=embed_dims[0], + resolution=img_size, + activation=activation) + + patches_resolution = self.patch_embed.patches_resolution + self.patches_resolution = patches_resolution + + # stochastic depth + dpr = [x.item() for x in torch.linspace(0, drop_path_rate, + sum(depths))] # stochastic depth decay rule + + # build layers + self.layers = nn.ModuleList() + for i_layer in range(self.num_layers): + kwargs = dict(dim=embed_dims[i_layer], + input_resolution=(patches_resolution[0] // (2 ** (i_layer-1 if i_layer == 3 else i_layer)), + patches_resolution[1] // (2 ** (i_layer-1 if i_layer == 3 else i_layer))), + # input_resolution=(patches_resolution[0] // (2 ** i_layer), + # patches_resolution[1] // (2 ** i_layer)), + depth=depths[i_layer], + drop_path=dpr[sum(depths[:i_layer]):sum(depths[:i_layer + 1])], + downsample=PatchMerging if ( + i_layer < self.num_layers - 1) else None, + use_checkpoint=use_checkpoint, + out_dim=embed_dims[min( + i_layer + 1, len(embed_dims) - 1)], + activation=activation, + ) + if i_layer == 0: + layer = ConvLayer( + conv_expand_ratio=mbconv_expand_ratio, + **kwargs, + ) + else: + layer = BasicLayer( + num_heads=num_heads[i_layer], + window_size=window_sizes[i_layer], + mlp_ratio=self.mlp_ratio, + drop=drop_rate, + local_conv_size=local_conv_size, + **kwargs) + self.layers.append(layer) + + # Classifier head + self.norm_head = nn.LayerNorm(embed_dims[-1]) + self.head = nn.Linear( + embed_dims[-1], num_classes) if num_classes > 0 else torch.nn.Identity() + + # init weights + self.apply(self._init_weights) + self.set_layer_lr_decay(layer_lr_decay) + self.neck = nn.Sequential( + nn.Conv2d( + embed_dims[-1], + 256, + kernel_size=1, + bias=False, + ), + LayerNorm2d(256), + nn.Conv2d( + 256, + 256, + kernel_size=3, + padding=1, + bias=False, + ), + LayerNorm2d(256), + ) + def set_layer_lr_decay(self, layer_lr_decay): + decay_rate = layer_lr_decay + + # layers -> blocks (depth) + depth = sum(self.depths) + lr_scales = [decay_rate ** (depth - i - 1) for i in range(depth)] + + def _set_lr_scale(m, scale): + for p in m.parameters(): + p.lr_scale = scale + + self.patch_embed.apply(lambda x: _set_lr_scale(x, lr_scales[0])) + i = 0 + for layer in self.layers: + for block in layer.blocks: + block.apply(lambda x: _set_lr_scale(x, lr_scales[i])) + i += 1 + if layer.downsample is not None: + layer.downsample.apply( + lambda x: _set_lr_scale(x, lr_scales[i - 1])) + assert i == depth + for m in [self.norm_head, self.head]: + m.apply(lambda x: _set_lr_scale(x, lr_scales[-1])) + + for k, p in self.named_parameters(): + p.param_name = k + + def _check_lr_scale(m): + for p in m.parameters(): + assert hasattr(p, 'lr_scale'), p.param_name + + self.apply(_check_lr_scale) + + def _init_weights(self, m): + if isinstance(m, nn.Linear): + trunc_normal_(m.weight, std=.02) + if isinstance(m, nn.Linear) and m.bias is not None: + nn.init.constant_(m.bias, 0) + elif isinstance(m, nn.LayerNorm): + nn.init.constant_(m.bias, 0) + nn.init.constant_(m.weight, 1.0) + + @torch.jit.ignore + def no_weight_decay_keywords(self): + return {'attention_biases'} + + def forward_features(self, x): + # x: (N, C, H, W) + x = self.patch_embed(x) + + x = self.layers[0](x) + start_i = 1 + + for i in range(start_i, len(self.layers)): + layer = self.layers[i] + x = layer(x) + B,_,C=x.size() + x = x.view(B, 64, 64, C) + x=x.permute(0, 3, 1, 2) + x=self.neck(x) + return x + + def forward(self, x): + x = self.forward_features(x) + #x = self.norm_head(x) + #x = self.head(x) + return x diff --git a/simple_syrup/third_party/sam_hq_runtime/predictor.py b/simple_syrup/third_party/sam_hq_runtime/predictor.py new file mode 100644 index 0000000..40e3162 --- /dev/null +++ b/simple_syrup/third_party/sam_hq_runtime/predictor.py @@ -0,0 +1,145 @@ +from typing import Optional, Tuple +import numpy as np +import torch +from segment_anything import SamPredictor +from segment_anything.modeling import Sam + + +class SamPredictorHQ(SamPredictor): + + def __init__( + self, + sam_model: Sam, + sam_is_hq: bool = False, + ) -> None: + """ + Uses SAM to calculate the image embedding for an image, and then + allow repeated, efficient mask prediction given prompts. + + Arguments: + sam_model (Sam): The model to use for mask prediction. + """ + super().__init__(sam_model=sam_model) + self.is_hq = sam_is_hq + + + @torch.no_grad() + def set_torch_image( + self, + transformed_image: torch.Tensor, + original_image_size: Tuple[int, ...], + ) -> None: + """ + Calculates the image embeddings for the provided image, allowing + masks to be predicted with the 'predict' method. Expects the input + image to be already transformed to the format expected by the model. + + Arguments: + transformed_image (torch.Tensor): The input image, with shape + 1x3xHxW, which has been transformed with ResizeLongestSide. + original_image_size (tuple(int, int)): The size of the image + before transformation, in (H, W) format. + """ + assert ( + len(transformed_image.shape) == 4 + and transformed_image.shape[1] == 3 + and max(*transformed_image.shape[2:]) == self.model.image_encoder.img_size + ), f"set_torch_image input must be BCHW with long side {self.model.image_encoder.img_size}." + self.reset_image() + + self.original_size = original_image_size + self.input_size = tuple(transformed_image.shape[-2:]) + input_image = self.model.preprocess(transformed_image) + if self.is_hq: + self.features, self.interm_features = self.model.image_encoder(input_image) + else: + self.features = self.model.image_encoder(input_image) + self.is_image_set = True + + + @torch.no_grad() + def predict_torch( + self, + point_coords: Optional[torch.Tensor], + point_labels: Optional[torch.Tensor], + boxes: Optional[torch.Tensor] = None, + mask_input: Optional[torch.Tensor] = None, + multimask_output: bool = True, + return_logits: bool = False, + ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Predict masks for the given input prompts, using the currently set image. + Input prompts are batched torch tensors and are expected to already be + transformed to the input frame using ResizeLongestSide. + + Arguments: + point_coords (torch.Tensor or None): A BxNx2 array of point prompts to the + model. Each point is in (X,Y) in pixels. + point_labels (torch.Tensor or None): A BxN array of labels for the + point prompts. 1 indicates a foreground point and 0 indicates a + background point. + boxes (np.ndarray or None): A Bx4 array given a box prompt to the + model, in XYXY format. + mask_input (np.ndarray): A low resolution mask input to the model, typically + coming from a previous prediction iteration. Has form Bx1xHxW, where + for SAM, H=W=256. Masks returned by a previous iteration of the + predict method do not need further transformation. + multimask_output (bool): If true, the model will return three masks. + For ambiguous input prompts (such as a single click), this will often + produce better masks than a single prediction. If only a single + mask is needed, the model's predicted quality score can be used + to select the best mask. For non-ambiguous prompts, such as multiple + input prompts, multimask_output=False can give better results. + return_logits (bool): If true, returns un-thresholded masks logits + instead of a binary mask. + + Returns: + (torch.Tensor): The output masks in BxCxHxW format, where C is the + number of masks, and (H, W) is the original image size. + (torch.Tensor): An array of shape BxC containing the model's + predictions for the quality of each mask. + (torch.Tensor): An array of shape BxCxHxW, where C is the number + of masks and H=W=256. These low res logits can be passed to + a subsequent iteration as mask input. + """ + if not self.is_image_set: + raise RuntimeError("An image must be set with .set_image(...) before mask prediction.") + + if point_coords is not None: + points = (point_coords, point_labels) + else: + points = None + + # Embed prompts + sparse_embeddings, dense_embeddings = self.model.prompt_encoder( + points=points, + boxes=boxes, + masks=mask_input, + ) + + # Predict masks + if self.is_hq: + low_res_masks, iou_predictions = self.model.mask_decoder( + image_embeddings=self.features, + image_pe=self.model.prompt_encoder.get_dense_pe(), + sparse_prompt_embeddings=sparse_embeddings, + dense_prompt_embeddings=dense_embeddings, + multimask_output=multimask_output, + hq_token_only=False, + interm_embeddings=self.interm_features, + ) + else: + low_res_masks, iou_predictions = self.model.mask_decoder( + image_embeddings=self.features, + image_pe=self.model.prompt_encoder.get_dense_pe(), + sparse_prompt_embeddings=sparse_embeddings, + dense_prompt_embeddings=dense_embeddings, + multimask_output=multimask_output, + ) + # Upscale the masks to the original image resolution + masks = self.model.postprocess_masks(low_res_masks, self.input_size, self.original_size) + + if not return_logits: + masks = masks > self.model.mask_threshold + + return masks, iou_predictions, low_res_masks diff --git a/test_helpers.py b/test_helpers.py new file mode 100644 index 0000000..a1fb33c --- /dev/null +++ b/test_helpers.py @@ -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 + +"""Shared typed helpers for SimpleSyrup tests.""" + +from __future__ import annotations + +from pathlib import Path +from types import ModuleType + +import torch + + +def make_image_tensor( + batch_size: int = 1, + height: int = 4, + width: int = 6, + channels: int = 3, +) -> torch.Tensor: + """Create a deterministic BHWC image tensor for tests.""" + + values = torch.arange( + batch_size * height * width * channels, + dtype=torch.float32, + ) + return values.reshape(batch_size, height, width, channels) / values.numel() + + +def make_mask_tensor( + batch_size: int = 1, + height: int = 4, + width: int = 6, +) -> torch.Tensor: + """Create a deterministic BHW mask tensor for tests.""" + + values = torch.arange(batch_size * height * width, dtype=torch.float32) + return values.reshape(batch_size, height, width) / values.numel() + + +class FakeFolderPaths(ModuleType): + """Small fake for the ComfyUI folder_paths module.""" + + def __init__(self, models_dir: Path) -> None: + """Create fake folder path state.""" + + super().__init__("folder_paths") + self.models_dir = str(models_dir) + self.folder_names_and_paths: dict[str, tuple[list[str], set[str]]] = {} + + def add_model_folder_path(self, folder_name: str, full_folder_path: str) -> None: + """Record a registered folder.""" + + self.folder_names_and_paths[folder_name] = ([full_folder_path], set()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9503b03 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Pytest configuration for SimpleSyrup tests.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +COMFY_ROOT = PROJECT_ROOT.parents[1] +CUSTOM_NODES_ROOT = PROJECT_ROOT.parent + +for path in (PROJECT_ROOT, COMFY_ROOT, CUSTOM_NODES_ROOT): + path_text = str(path) + if path_text not in sys.path: + sys.path.insert(0, path_text) + +if os.environ.get("SIMPLE_SYRUP_TEST_COMFY_CPU") == "1": + from comfy.cli_args import args + + args.cpu = True diff --git a/tests/test_a1111_sampling.py b/tests/test_a1111_sampling.py new file mode 100644 index 0000000..8b6224b --- /dev/null +++ b/tests/test_a1111_sampling.py @@ -0,0 +1,197 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for AUTOMATIC1111-derived sampler functions.""" + +from __future__ import annotations + +from collections.abc import Callable +from importlib import import_module + +import pytest +import torch + +from simple_syrup.runtime import a1111_sampling + + +class FakeModel: + """Provide a deterministic denoiser for sampler-loop tests.""" + + def __init__(self) -> None: + """Create a fake model with call recording.""" + + self.calls: list[dict[str, object]] = [] + + def __call__( + self, + x: torch.Tensor, + sigma: torch.Tensor, + **kwargs: object, + ) -> torch.Tensor: + """Return a deterministic denoised tensor and record call arguments.""" + + self.calls.append({"x": x.clone(), "sigma": sigma.clone(), "kwargs": kwargs}) + return x * 0.5 + + +def reference_euler_ancestral( + model: Callable[..., torch.Tensor], + x: torch.Tensor, + sigmas: torch.Tensor, + noise_sampler: Callable[[torch.Tensor, torch.Tensor], torch.Tensor], +) -> torch.Tensor: + """Calculate the A1111/k-diffusion Euler ancestral reference loop.""" + + s_in = x.new_ones([x.shape[0]]) + for index in range(len(sigmas) - 1): + denoised = model(x, sigmas[index] * s_in) + sigma_down, sigma_up = reference_ancestral_step( + sigmas[index], + sigmas[index + 1], + ) + derivative = (x - denoised) / sigmas[index] + x = x + derivative * (sigma_down - sigmas[index]) + if sigmas[index + 1] > 0: + x = x + noise_sampler(sigmas[index], sigmas[index + 1]) * sigma_up + return x + + +def reference_ancestral_step( + sigma_from: torch.Tensor, + sigma_to: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + """Calculate k-diffusion's default eta=1 ancestral step.""" + + sigma_up = torch.minimum( + sigma_to, + (sigma_to**2 * (sigma_from**2 - sigma_to**2) / sigma_from**2).sqrt(), + ) + sigma_down = (sigma_to**2 - sigma_up**2).sqrt() + return sigma_down, sigma_up + + +def test_euler_a_a1111_matches_reference_loop_with_deterministic_noise() -> None: + """The local sampler follows the A1111/k-diffusion Euler ancestral loop.""" + + x = torch.tensor([[1.0, -2.0]], dtype=torch.float32) + sigmas = torch.tensor([1.0, 0.5, 0.0], dtype=torch.float32) + noise_calls: list[tuple[torch.Tensor, torch.Tensor]] = [] + + def noise_sampler( + sigma: torch.Tensor, + sigma_next: torch.Tensor, + ) -> torch.Tensor: + """Return deterministic ancestral noise and record call arguments.""" + + noise_calls.append((sigma.clone(), sigma_next.clone())) + return torch.full_like(x, 0.25) + + expected = reference_euler_ancestral(FakeModel(), x.clone(), sigmas, noise_sampler) + noise_calls.clear() + + actual = a1111_sampling.sample_euler_ancestral_a1111( + FakeModel(), + x.clone(), + sigmas, + noise_sampler=noise_sampler, + ) + + assert torch.allclose(actual, expected, atol=1e-6, rtol=1e-6) + assert len(noise_calls) == 1 + assert torch.equal(noise_calls[0][0], sigmas[0]) + assert torch.equal(noise_calls[0][1], sigmas[1]) + + +def test_euler_a_a1111_callback_and_extra_args() -> None: + """The sampler forwards extra args and emits k-diffusion callback payloads.""" + + model = FakeModel() + callback_payloads: list[dict[str, object]] = [] + x = torch.ones((1, 2), dtype=torch.float32) + sigmas = torch.tensor([1.0, 0.0], dtype=torch.float32) + + a1111_sampling.sample_euler_ancestral_a1111( + model, + x, + sigmas, + extra_args={"seed": 123, "model_options": {"kept": True}}, + callback=callback_payloads.append, + noise_sampler=lambda _sigma, _sigma_next: torch.zeros_like(x), + ) + + assert model.calls[0]["kwargs"] == { + "seed": 123, + "model_options": {"kept": True}, + } + assert len(callback_payloads) == 1 + payload = callback_payloads[0] + assert payload["i"] == 0 + assert isinstance(payload["sigma"], torch.Tensor) + assert isinstance(payload["sigma_hat"], torch.Tensor) + assert torch.equal(payload["sigma"], sigmas[0]) + assert torch.equal(payload["sigma_hat"], sigmas[0]) + assert isinstance(payload["denoised"], torch.Tensor) + + +def test_euler_a_a1111_uses_arithmetic_path_for_final_zero_step( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The final zero transition still calls to_d instead of Comfy's shortcut.""" + + calls: list[torch.Tensor] = [] + + def fake_to_d( + x: torch.Tensor, + sigma: torch.Tensor, + denoised: torch.Tensor, + ) -> torch.Tensor: + """Record derivative conversion for the final zero step.""" + + del sigma, denoised + calls.append(x.clone()) + return torch.ones_like(x) + + monkeypatch.setattr(a1111_sampling, "_to_d", fake_to_d) + + a1111_sampling.sample_euler_ancestral_a1111( + FakeModel(), + torch.ones((1, 2), dtype=torch.float32), + torch.tensor([1.0, 0.0], dtype=torch.float32), + noise_sampler=lambda _sigma, _sigma_next: torch.zeros((1, 2)), + ) + + assert len(calls) == 1 + + +def test_euler_a_a1111_default_noise_sampler_uses_comfy_seed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Default noise delegates to ComfyUI's deterministic seed-aware helper.""" + + comfy_sampling = import_module("comfy.k_diffusion.sampling") + calls: list[object] = [] + + def fake_default_noise_sampler( + x: torch.Tensor, + seed: object = None, + ) -> Callable[[torch.Tensor, torch.Tensor], torch.Tensor]: + """Record seed delegation and return deterministic noise.""" + + calls.append(seed) + return lambda _sigma, _sigma_next: torch.zeros_like(x) + + monkeypatch.setattr( + comfy_sampling, + "default_noise_sampler", + fake_default_noise_sampler, + ) + + a1111_sampling.sample_euler_ancestral_a1111( + FakeModel(), + torch.ones((1, 2), dtype=torch.float32), + torch.tensor([1.0, 0.0], dtype=torch.float32), + extra_args={"seed": 456}, + ) + + assert calls == [456] diff --git a/tests/test_anima_loader.py b/tests/test_anima_loader.py new file mode 100644 index 0000000..23047aa --- /dev/null +++ b/tests/test_anima_loader.py @@ -0,0 +1,306 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Anima combined model loader service.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from types import ModuleType + +import pytest +import torch + +from simple_syrup.runtime.anima_loader import ( + AUTO_CHOICE, + AnimaLoaderService, +) +from simple_syrup.runtime.auto_model_resolver import AutoModelResolution +from simple_syrup.runtime.model_catalog import AutoModelArtifact +from simple_syrup.runtime.vae_loader import vae_choices + + +@dataclass +class FakeComfyState: + """Recorded calls into fake ComfyUI loader APIs.""" + + diffusion_calls: list[tuple[str, dict[str, object]]] = field(default_factory=list) + clip_calls: list[dict[str, object]] = field(default_factory=list) + vae_paths: list[str] = field(default_factory=list) + + +class FakeFolderPaths(ModuleType): + """Folder paths fake with ComfyUI loader methods.""" + + def __init__(self, models_dir: Path) -> None: + """Create fake folder state.""" + + super().__init__("folder_paths") + self.models_dir = str(models_dir) + self.files: dict[str, list[str]] = { + "diffusion_models": ["anima.safetensors"], + "text_encoders": ["manual_clip.safetensors"], + "vae": ["manual_vae.safetensors"], + "vae_approx": [], + "embeddings": [], + } + + def get_filename_list(self, folder_name: str) -> list[str]: + """Return configured relative filenames.""" + + return self.files.get(folder_name, []) + + def get_folder_paths(self, folder_name: str) -> list[str]: + """Return the conventional folder path for a model type.""" + + return [str(Path(self.models_dir) / folder_name)] + + def get_full_path_or_raise(self, folder_name: str, filename: str) -> str: + """Return a deterministic absolute path for a relative filename.""" + + return str(Path(self.models_dir) / folder_name / filename) + + +class FakeResolver: + """Resolver fake returning paths for Anima auto artifacts.""" + + def __init__(self, text_encoder_path: Path, vae_path: Path) -> None: + """Create a resolver with deterministic paths.""" + + self.text_encoder_path = text_encoder_path + self.vae_path = vae_path + self.requests: list[str] = [] + + def resolve( + self, + artifact: AutoModelArtifact, + progress: object | None = None, + ) -> AutoModelResolution: + """Record and resolve one artifact.""" + + del progress + self.requests.append(artifact.cache_id) + if artifact.folder_name == "text_encoders": + return AutoModelResolution(self.text_encoder_path, "cached") + return AutoModelResolution(self.vae_path, "cached") + + +def test_loader_maps_diffusion_weight_dtype( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Diffusion dtype options match ComfyUI's UNETLoader behavior.""" + + comfy_state = _install_fake_comfy(monkeypatch) + folder_paths = FakeFolderPaths(tmp_path / "models") + service = AnimaLoaderService( + resolver=FakeResolver( + tmp_path / "clip.safetensors", tmp_path / "vae.safetensors" + ), + folder_paths_module=folder_paths, + ) + + service.load_models( + "anima.safetensors", + "fp8_e4m3fn_fast", + "manual_clip.safetensors", + "default", + "manual_vae.safetensors", + ) + + assert comfy_state.diffusion_calls[0][1] == { + "dtype": torch.float8_e4m3fn, + "fp8_optimizations": True, + } + + +def test_loader_maps_clip_cpu_device( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CLIP cpu device option matches ComfyUI's CLIPLoader behavior.""" + + comfy_state = _install_fake_comfy(monkeypatch) + folder_paths = FakeFolderPaths(tmp_path / "models") + service = AnimaLoaderService( + resolver=FakeResolver( + tmp_path / "clip.safetensors", tmp_path / "vae.safetensors" + ), + folder_paths_module=folder_paths, + ) + + service.load_models( + "anima.safetensors", + "default", + "manual_clip.safetensors", + "cpu", + "manual_vae.safetensors", + ) + + model_options = comfy_state.clip_calls[0]["model_options"] + assert model_options == { + "load_device": torch.device("cpu"), + "offload_device": torch.device("cpu"), + } + clip_type = comfy_state.clip_calls[0]["clip_type"] + assert isinstance(clip_type, Enum) + assert clip_type.name == "STABLE_DIFFUSION" + + +def test_loader_uses_auto_resolver_for_auto_choices( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto text encoder and VAE selections use resolved auto paths.""" + + comfy_state = _install_fake_comfy(monkeypatch) + folder_paths = FakeFolderPaths(tmp_path / "models") + resolver = FakeResolver( + tmp_path / "models" / "text_encoders" / "qwen" / "qwen_3_06b_base.safetensors", + tmp_path / "models" / "vae" / "qwen" / "qwen_image_vae.safetensors", + ) + service = AnimaLoaderService(resolver=resolver, folder_paths_module=folder_paths) + + service.load_models( + "anima.safetensors", + "default", + AUTO_CHOICE, + "default", + AUTO_CHOICE, + ) + + assert resolver.requests == ["anima_qwen_text_encoder", "anima_qwen_vae"] + assert comfy_state.clip_calls[0]["ckpt_paths"] == [str(resolver.text_encoder_path)] + assert comfy_state.vae_paths == [str(resolver.vae_path)] + + +def test_loader_returns_model_clip_and_vae( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Service returns the combined ComfyUI loader outputs.""" + + comfy_state = _install_fake_comfy(monkeypatch) + folder_paths = FakeFolderPaths(tmp_path / "models") + service = AnimaLoaderService( + resolver=FakeResolver( + tmp_path / "clip.safetensors", tmp_path / "vae.safetensors" + ), + folder_paths_module=folder_paths, + ) + + result = service.load_models( + "anima.safetensors", + "default", + "manual_clip.safetensors", + "default", + "manual_vae.safetensors", + ) + + assert result[0] == "model" + assert result[1] == "clip" + assert result[2] is not None + assert comfy_state.vae_paths == [ + str(tmp_path / "models" / "vae" / "manual_vae.safetensors") + ] + + +def test_vae_choices_match_comfy_special_choices(tmp_path: Path) -> None: + """VAE choices include ComfyUI TAESD and pixel-space entries.""" + + folder_paths = FakeFolderPaths(tmp_path / "models") + folder_paths.files["vae"] = ["manual_vae.safetensors"] + folder_paths.files["vae_approx"] = [ + "taesd_encoder.pth", + "taesd_decoder.pth", + "taehv.pth", + ] + + assert vae_choices(folder_paths) == [ + "manual_vae.safetensors", + "taehv.pth", + "taesd", + "pixel_space", + ] + + +def _install_fake_comfy(monkeypatch: pytest.MonkeyPatch) -> FakeComfyState: + """Install fake ComfyUI modules and return the fake comfy.sd module.""" + + class FakeCLIPType(Enum): + """Small CLIPType enum fake.""" + + STABLE_DIFFUSION = 1 + QWEN_IMAGE = 2 + + class FakeVAE: + """Small VAE fake.""" + + def __init__( + self, sd: dict[str, object], metadata: object | None = None + ) -> None: + """Record constructor inputs.""" + + del sd, metadata + + def throw_exception_if_invalid(self) -> None: + """Accept validation.""" + + comfy_module = ModuleType("comfy") + comfy_sd = ModuleType("comfy.sd") + comfy_utils = ModuleType("comfy.utils") + state = FakeComfyState() + comfy_sd.CLIPType = FakeCLIPType # type: ignore[attr-defined] + + def load_diffusion_model( + path: str, + model_options: dict[str, object], + ) -> str: + """Record diffusion model calls.""" + + state.diffusion_calls.append((path, model_options)) + return "model" + + def load_clip( + ckpt_paths: list[str], + embedding_directory: list[str], + clip_type: FakeCLIPType, + model_options: dict[str, object], + ) -> str: + """Record CLIP loader calls.""" + + state.clip_calls.append( + { + "ckpt_paths": ckpt_paths, + "embedding_directory": embedding_directory, + "clip_type": clip_type, + "model_options": model_options, + } + ) + return "clip" + + def load_torch_file( + path: str, + return_metadata: bool = False, + ) -> tuple[dict[str, object], dict[str, object]]: + """Record VAE file loads.""" + + del return_metadata + state.vae_paths.append(path) + return {}, {} + + comfy_sd.load_diffusion_model = load_diffusion_model # type: ignore[attr-defined] + comfy_sd.load_clip = load_clip # type: ignore[attr-defined] + comfy_sd.VAE = FakeVAE # type: ignore[attr-defined] + comfy_utils.load_torch_file = load_torch_file # type: ignore[attr-defined] + comfy_module.sd = comfy_sd # type: ignore[attr-defined] + comfy_module.utils = comfy_utils # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "comfy", comfy_module) + monkeypatch.setitem(sys.modules, "comfy.sd", comfy_sd) + monkeypatch.setitem(sys.modules, "comfy.utils", comfy_utils) + return state diff --git a/tests/test_auto_model_cache.py b/tests/test_auto_model_cache.py new file mode 100644 index 0000000..5f39291 --- /dev/null +++ b/tests/test_auto_model_cache.py @@ -0,0 +1,132 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for persisted automatic model path cache.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from simple_syrup.runtime.auto_model_cache import ( + AutoModelCache, + AutoModelCacheEntry, + AutoModelCacheError, +) +from test_helpers import FakeFolderPaths + + +def test_cache_uses_comfy_user_directory(tmp_path: Path) -> None: + """Cache path is rooted in ComfyUI's user directory when available.""" + + fake = FakeFolderPaths(tmp_path / "models") + fake.get_user_directory = lambda: str(tmp_path / "custom_user") # type: ignore[attr-defined] + + cache = AutoModelCache(fake) + + assert cache.cache_path() == ( + tmp_path / "custom_user" / "simple_syrup" / "auto_models.json" + ) + + +def test_cache_falls_back_to_user_next_to_models(tmp_path: Path) -> None: + """Cache path falls back beside models when ComfyUI lacks a helper.""" + + fake = FakeFolderPaths(tmp_path / "models") + + cache = AutoModelCache(fake) + + assert cache.cache_path() == tmp_path / "user" / "simple_syrup" / "auto_models.json" + + +def test_missing_cache_loads_empty_entries(tmp_path: Path) -> None: + """A missing cache file is treated as an empty cache.""" + + assert AutoModelCache(FakeFolderPaths(tmp_path / "models")).load() == {} + + +def test_cache_saves_and_loads_entry(tmp_path: Path) -> None: + """Cache entries round-trip through JSON persistence.""" + + cache = AutoModelCache(FakeFolderPaths(tmp_path / "models")) + entry = AutoModelCacheEntry( + folder_name="text_encoders", + filename="qwen_3_06b_base.safetensors", + path=tmp_path + / "models" + / "text_encoders" + / "qwen" + / "qwen_3_06b_base.safetensors", + source="downloaded", + sha256="abc123", + ) + + cache.save_entry("anima_qwen_text_encoder", entry) + + assert cache.load() == {"anima_qwen_text_encoder": entry} + + +def test_cache_preserves_unrelated_entries(tmp_path: Path) -> None: + """Updating one entry does not discard other cached artifacts.""" + + cache = AutoModelCache(FakeFolderPaths(tmp_path / "models")) + first = AutoModelCacheEntry( + folder_name="text_encoders", + filename="first.safetensors", + path=tmp_path / "first.safetensors", + source="found", + sha256="first", + ) + second = AutoModelCacheEntry( + folder_name="vae", + filename="second.safetensors", + path=tmp_path / "second.safetensors", + source="downloaded", + sha256="second", + ) + + cache.save_entry("first", first) + cache.save_entry("second", second) + + assert cache.load() == {"first": first, "second": second} + + +def test_cache_rejects_invalid_json_structure(tmp_path: Path) -> None: + """Invalid cache schema fails with actionable context.""" + + cache = AutoModelCache(FakeFolderPaths(tmp_path / "models")) + cache.cache_path().parent.mkdir(parents=True) + cache.cache_path().write_text(json.dumps({"entries": []}), encoding="utf-8") + + with pytest.raises(AutoModelCacheError, match="version"): + cache.load() + + +def test_cache_rejects_invalid_entry_field(tmp_path: Path) -> None: + """Invalid cache entry fields are rejected during load.""" + + cache = AutoModelCache(FakeFolderPaths(tmp_path / "models")) + cache.cache_path().parent.mkdir(parents=True) + cache.cache_path().write_text( + json.dumps( + { + "version": 1, + "entries": { + "bad": { + "folder_name": "vae", + "filename": "model.safetensors", + "path": "", + "source": "found", + "sha256": "abc", + } + }, + } + ), + encoding="utf-8", + ) + + with pytest.raises(AutoModelCacheError, match="path"): + cache.load() diff --git a/tests/test_auto_model_resolver.py b/tests/test_auto_model_resolver.py new file mode 100644 index 0000000..8df5907 --- /dev/null +++ b/tests/test_auto_model_resolver.py @@ -0,0 +1,225 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for automatic model artifact resolution.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from simple_syrup.runtime.auto_model_cache import AutoModelCache, AutoModelCacheEntry +from simple_syrup.runtime.auto_model_resolver import ( + AutoModelResolver, + canonical_auto_destination, + find_model_by_basename, + relative_model_name, +) +from simple_syrup.runtime.model_catalog import AutoModelArtifact +from simple_syrup.runtime.model_downloads import DownloadRequest, DownloadResult +from test_helpers import FakeFolderPaths + + +class RecordingDownloader: + """Downloader double that records requests and writes final files.""" + + def __init__(self, fail: bool = False) -> None: + """Create a downloader with optional failure behavior.""" + + self.fail = fail + self.requests: list[DownloadRequest] = [] + + def download( + self, + request: DownloadRequest, + progress: object | None = None, + ) -> DownloadResult: + """Record and satisfy one trusted download request.""" + + del progress + self.requests.append(request) + if self.fail: + raise ValueError("checksum mismatch") + request.destination_path.parent.mkdir(parents=True, exist_ok=True) + request.destination_path.write_bytes(b"model") + return DownloadResult( + path=request.destination_path, + bytes_downloaded=5, + skipped_existing=False, + ) + + +def test_resolver_returns_valid_cached_path(tmp_path: Path) -> None: + """A valid remembered path is reused without search or download.""" + + fake = FakeFolderPaths(tmp_path / "models") + artifact = _artifact("text_encoders", "model.safetensors") + cached_path = tmp_path / "models" / "text_encoders" / "qwen" / artifact.filename + cached_path.parent.mkdir(parents=True) + cached_path.write_bytes(b"model") + cache = AutoModelCache(fake) + cache.save_entry( + artifact.cache_id, + AutoModelCacheEntry( + folder_name=artifact.folder_name, + filename=artifact.filename, + path=cached_path, + source="downloaded", + sha256=artifact.sha256, + ), + ) + downloader = RecordingDownloader() + + resolved = AutoModelResolver(cache, downloader, fake).resolve(artifact) + + assert resolved.path == cached_path + assert resolved.source == "cached" + assert downloader.requests == [] + + +def test_resolver_repairs_stale_cache_with_recursive_search(tmp_path: Path) -> None: + """Missing cached files trigger recursive search and cache update.""" + + fake = FakeFolderPaths(tmp_path / "models") + artifact = _artifact("text_encoders", "model.safetensors") + found_path = tmp_path / "models" / "text_encoders" / "nested" / artifact.filename + found_path.parent.mkdir(parents=True) + found_path.write_bytes(b"model") + cache = AutoModelCache(fake) + cache.save_entry( + artifact.cache_id, + AutoModelCacheEntry( + folder_name=artifact.folder_name, + filename=artifact.filename, + path=tmp_path / "missing.safetensors", + source="downloaded", + sha256=artifact.sha256, + ), + ) + + resolved = AutoModelResolver(cache, RecordingDownloader(), fake).resolve(artifact) + + assert resolved.path == found_path + assert resolved.source == "found" + assert cache.load()[artifact.cache_id].path == found_path + + +def test_find_model_by_basename_respects_folder_priority(tmp_path: Path) -> None: + """Recursive search prefers earlier ComfyUI model roots.""" + + fake = FakeFolderPaths(tmp_path / "models") + first = tmp_path / "external" / "text_encoders" + second = tmp_path / "models" / "text_encoders" + fake.folder_names_and_paths["text_encoders"] = ([str(first), str(second)], set()) + (first / "a").mkdir(parents=True) + (second / "b").mkdir(parents=True) + first_match = first / "a" / "model.safetensors" + second_match = second / "b" / "model.safetensors" + first_match.write_bytes(b"first") + second_match.write_bytes(b"second") + + assert ( + find_model_by_basename("text_encoders", "model.safetensors", fake) + == first_match + ) + + +def test_resolver_downloads_to_first_registered_folder(tmp_path: Path) -> None: + """Missing artifacts download to the canonical subfolder under the first root.""" + + fake = FakeFolderPaths(tmp_path / "models") + first = tmp_path / "external" / "vae" + second = tmp_path / "models" / "vae" + fake.folder_names_and_paths["vae"] = ([str(first), str(second)], set()) + artifact = _artifact("vae", "vae.safetensors") + cache = AutoModelCache(fake) + downloader = RecordingDownloader() + + resolved = AutoModelResolver(cache, downloader, fake).resolve(artifact) + + expected = first / "qwen" / "vae.safetensors" + assert resolved.path == expected + assert downloader.requests[0].destination_path == expected + assert downloader.requests[0].expected_folder == first + assert downloader.requests[0].expected_sha256 == artifact.sha256 + assert cache.load()[artifact.cache_id].source == "downloaded" + + +def test_canonical_destination_rejects_unsafe_subfolder(tmp_path: Path) -> None: + """Catalog paths cannot escape the model root.""" + + fake = FakeFolderPaths(tmp_path / "models") + artifact = AutoModelArtifact( + cache_id="bad", + filename="model.safetensors", + folder_name="vae", + canonical_subfolder="..", + source_url="https://example.invalid/model.safetensors", + source_repo="example/model", + description="bad", + sha256="abc", + ) + + with pytest.raises(ValueError, match="not safe"): + canonical_auto_destination(artifact, fake) + + +@pytest.mark.parametrize( + "unsafe_basename", + ("nested/model.safetensors", "nested\\model.safetensors"), +) +def test_find_model_by_basename_rejects_relative_paths( + tmp_path: Path, + unsafe_basename: str, +) -> None: + """Search only accepts basenames, not relative paths.""" + + with pytest.raises(ValueError, match="not safe"): + find_model_by_basename( + "vae", + unsafe_basename, + FakeFolderPaths(tmp_path / "models"), + ) + + +def test_relative_model_name_returns_comfy_relative_path(tmp_path: Path) -> None: + """Resolved paths can be converted back to ComfyUI relative names.""" + + fake = FakeFolderPaths(tmp_path / "models") + path = tmp_path / "models" / "vae" / "qwen" / "vae.safetensors" + path.parent.mkdir(parents=True) + path.write_bytes(b"model") + + assert relative_model_name("vae", path, fake) == str( + Path("qwen") / "vae.safetensors" + ) + + +def test_resolver_does_not_cache_failed_download(tmp_path: Path) -> None: + """Failed downloads leave the auto cache unchanged.""" + + fake = FakeFolderPaths(tmp_path / "models") + artifact = _artifact("vae", "vae.safetensors") + cache = AutoModelCache(fake) + + with pytest.raises(ValueError, match="checksum mismatch"): + AutoModelResolver(cache, RecordingDownloader(fail=True), fake).resolve(artifact) + + assert artifact.cache_id not in cache.load() + + +def _artifact(folder_name: str, filename: str) -> AutoModelArtifact: + """Create a trusted artifact fixture.""" + + return AutoModelArtifact( + cache_id=f"{folder_name}_{filename}", + filename=filename, + folder_name=folder_name, + canonical_subfolder="qwen", + source_url=f"https://example.invalid/{filename}", + source_repo="example/model", + description=f"test {filename}", + sha256="abc123", + ) diff --git a/tests/test_bert_resolver.py b/tests/test_bert_resolver.py new file mode 100644 index 0000000..bffd883 --- /dev/null +++ b/tests/test_bert_resolver.py @@ -0,0 +1,116 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for BERT text encoder resolution.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from simple_syrup.runtime.bert_resolver import BertResolver, is_valid_bert_directory +from simple_syrup.runtime.model_downloads import DownloadRequest, DownloadResult +from test_helpers import FakeFolderPaths + + +class RecordingDownloader: + """Downloader double that writes requested artifact files.""" + + def __init__(self) -> None: + """Create an empty recording downloader.""" + + self.requests: list[DownloadRequest] = [] + + def download( + self, + request: DownloadRequest, + progress: object | None = None, + ) -> DownloadResult: + """Record and satisfy one download request.""" + + self.requests.append(request) + request.destination_path.parent.mkdir(parents=True, exist_ok=True) + request.destination_path.write_bytes(b"artifact") + return DownloadResult( + path=request.destination_path, + bytes_downloaded=8, + skipped_existing=False, + ) + + +def test_valid_bert_directory_requires_config_tokenizer_and_weights( + tmp_path: Path, +) -> None: + """BERT validation checks the minimum Hugging Face directory shape.""" + + bert = tmp_path / "bert" + bert.mkdir() + assert not is_valid_bert_directory(bert) + + (bert / "config.json").write_text("{}", encoding="utf-8") + (bert / "tokenizer.json").write_text("{}", encoding="utf-8") + (bert / "model.safetensors").write_bytes(b"weights") + + assert is_valid_bert_directory(bert) + + +def test_resolver_prefers_layerstyle_bert_path(tmp_path: Path) -> None: + """LayerStyle-compatible BERT wins when present.""" + + fake = FakeFolderPaths(tmp_path) + _write_bert(tmp_path / "bert-base-uncased") + _write_bert(tmp_path / "text_encoders" / "bert") + + resolved = BertResolver(folder_paths_module=fake).resolve(auto_download=False) + + assert resolved.path == tmp_path / "bert-base-uncased" + assert resolved.downloaded is False + + +def test_resolver_uses_text_encoder_bert_when_layerstyle_path_absent( + tmp_path: Path, +) -> None: + """ComfyUI text_encoders BERT is used as the second local option.""" + + fake = FakeFolderPaths(tmp_path) + _write_bert(tmp_path / "text_encoders" / "bert") + + resolved = BertResolver(folder_paths_module=fake).resolve(auto_download=False) + + assert resolved.path == tmp_path / "text_encoders" / "bert" + + +def test_resolver_downloads_to_text_encoder_bert(tmp_path: Path) -> None: + """Missing BERT downloads into models/text_encoders/bert.""" + + fake = FakeFolderPaths(tmp_path) + downloader = RecordingDownloader() + + resolved = BertResolver( + downloader=downloader, # type: ignore[arg-type] + folder_paths_module=fake, + ).resolve(auto_download=True) + + assert resolved.path == tmp_path / "text_encoders" / "bert" + assert resolved.downloaded is True + assert downloader.requests + + +def test_resolver_errors_when_missing_and_download_disabled(tmp_path: Path) -> None: + """Missing BERT with downloads disabled fails clearly.""" + + fake = FakeFolderPaths(tmp_path) + + with pytest.raises(FileNotFoundError, match="BERT text encoder was not found"): + BertResolver(folder_paths_module=fake).resolve(auto_download=False) + + +def _write_bert(path: Path) -> None: + """Write a minimal valid BERT directory.""" + + path.mkdir(parents=True) + (path / "config.json").write_text("{}", encoding="utf-8") + (path / "tokenizer.json").write_text("{}", encoding="utf-8") + (path / "model.safetensors").write_bytes(b"weights") diff --git a/tests/test_checkpoint_loader.py b/tests/test_checkpoint_loader.py new file mode 100644 index 0000000..db5380d --- /dev/null +++ b/tests/test_checkpoint_loader.py @@ -0,0 +1,275 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for checkpoint loading with optional VAE replacement.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path +from types import ModuleType + +import pytest + +from simple_syrup.runtime.checkpoint_loader import ( + CLIP_SKIP_DEFAULT, + CLIP_SKIP_LAYER, + USE_CHECKPOINT_VAE_CHOICE, + CheckpointLoaderService, +) + + +class FakeClip: + """CLIP double that records clone and layer selection behavior.""" + + def __init__(self, name: str = "checkpoint_clip") -> None: + """Create a CLIP double with no selected layer.""" + + self.name = name + self.layer: int | None = None + self.clone_count = 0 + + def clone(self) -> FakeClip: + """Return an independent CLIP double and record the clone call.""" + + self.clone_count += 1 + return FakeClip(f"{self.name}_clone") + + def clip_layer(self, layer: int) -> None: + """Record the selected CLIP layer.""" + + self.layer = layer + + +@dataclass +class FakeComfyState: + """Recorded calls into fake ComfyUI checkpoint APIs.""" + + checkpoint_clip: FakeClip = field(default_factory=FakeClip) + checkpoint_calls: list[dict[str, object]] = field(default_factory=list) + + +class FakeFolderPaths(ModuleType): + """Folder paths fake with checkpoint loader methods.""" + + def __init__(self, models_dir: Path) -> None: + """Create fake checkpoint folder state.""" + + super().__init__("folder_paths") + self.models_dir = str(models_dir) + + def get_folder_paths(self, folder_name: str) -> list[str]: + """Return a deterministic folder path for a model type.""" + + return [str(Path(self.models_dir) / folder_name)] + + def get_full_path_or_raise(self, folder_name: str, filename: str) -> str: + """Return a deterministic absolute path for a relative filename.""" + + return str(Path(self.models_dir) / folder_name / filename) + + +class FakeVaeLoader: + """External VAE loader double.""" + + def __init__(self) -> None: + """Create call recording state.""" + + self.requests: list[str] = [] + + def load_vae(self, vae_name: str) -> object: + """Return a fixed external VAE object.""" + + self.requests.append(vae_name) + if vae_name == "missing_vae.safetensors": + raise ValueError("missing VAE") + return "external_vae" + + +def test_checkpoint_loader_returns_checkpoint_vae_with_clip_skip_disabled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The false selection preserves the loaded CLIP and checkpoint VAE.""" + + state = _install_fake_comfy(monkeypatch) + vae_loader = FakeVaeLoader() + folder_paths = FakeFolderPaths(tmp_path / "models") + service = CheckpointLoaderService( + folder_paths_module=folder_paths, + vae_loader=vae_loader, + ) + + result = service.load_checkpoint( + ckpt_name="model.safetensors", + vae_name=USE_CHECKPOINT_VAE_CHOICE, + clip_skip=CLIP_SKIP_DEFAULT, + ) + + assert result == ("checkpoint_model", state.checkpoint_clip, "checkpoint_vae") + assert state.checkpoint_clip.clone_count == 0 + assert state.checkpoint_clip.layer is None + assert vae_loader.requests == [] + assert state.checkpoint_calls == [ + { + "ckpt_path": str(tmp_path / "models" / "checkpoints" / "model.safetensors"), + "output_vae": True, + "output_clip": True, + "embedding_directory": [str(tmp_path / "models" / "embeddings")], + } + ] + + +def test_checkpoint_loader_applies_clip_skip_to_checkpoint_vae_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The true selection applies Comfy's -2 layer to a cloned CLIP.""" + + state = _install_fake_comfy(monkeypatch) + service = CheckpointLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path / "models"), + vae_loader=FakeVaeLoader(), + ) + + result = service.load_checkpoint( + ckpt_name="model.safetensors", + vae_name=USE_CHECKPOINT_VAE_CHOICE, + clip_skip=True, + ) + + assert result[0] == "checkpoint_model" + assert result[1] is not state.checkpoint_clip + assert isinstance(result[1], FakeClip) + assert result[1].name == "checkpoint_clip_clone" + assert result[1].layer == CLIP_SKIP_LAYER + assert result[2] == "checkpoint_vae" + assert state.checkpoint_clip.clone_count == 1 + assert state.checkpoint_clip.layer is None + + +def test_checkpoint_loader_replaces_only_vae( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """External VAE selection replaces only the VAE output.""" + + state = _install_fake_comfy(monkeypatch) + vae_loader = FakeVaeLoader() + service = CheckpointLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path / "models"), + vae_loader=vae_loader, + ) + + result = service.load_checkpoint( + ckpt_name="model.safetensors", + vae_name="external_vae.safetensors", + clip_skip=False, + ) + + assert result == ("checkpoint_model", state.checkpoint_clip, "external_vae") + assert state.checkpoint_clip.clone_count == 0 + assert vae_loader.requests == ["external_vae.safetensors"] + + +def test_checkpoint_loader_applies_clip_skip_to_external_vae_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """External VAE replacement remains independent of clip skip.""" + + state = _install_fake_comfy(monkeypatch) + vae_loader = FakeVaeLoader() + service = CheckpointLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path / "models"), + vae_loader=vae_loader, + ) + + result = service.load_checkpoint( + ckpt_name="model.safetensors", + vae_name="external_vae.safetensors", + clip_skip=True, + ) + + assert result[0] == "checkpoint_model" + assert result[1] is not state.checkpoint_clip + assert isinstance(result[1], FakeClip) + assert result[1].layer == CLIP_SKIP_LAYER + assert result[2] == "external_vae" + assert vae_loader.requests == ["external_vae.safetensors"] + + +def test_checkpoint_loader_does_not_swallow_external_vae_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """External VAE load failures surface to ComfyUI callers.""" + + _install_fake_comfy(monkeypatch) + service = CheckpointLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path / "models"), + vae_loader=FakeVaeLoader(), + ) + + with pytest.raises(ValueError, match="missing VAE"): + service.load_checkpoint( + ckpt_name="model.safetensors", + vae_name="missing_vae.safetensors", + clip_skip=False, + ) + + +def test_checkpoint_loader_rejects_non_boolean_clip_skip(tmp_path: Path) -> None: + """Non-boolean clip skip values fail before checkpoint loading.""" + + service = CheckpointLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path / "models"), + vae_loader=FakeVaeLoader(), + ) + + with pytest.raises(TypeError, match="clip_skip.*boolean"): + service.load_checkpoint( + ckpt_name="model.safetensors", + vae_name=USE_CHECKPOINT_VAE_CHOICE, + clip_skip="on", # type: ignore[arg-type] + ) + + +def _install_fake_comfy(monkeypatch: pytest.MonkeyPatch) -> FakeComfyState: + """Install fake ComfyUI checkpoint modules.""" + + comfy_module = ModuleType("comfy") + comfy_sd = ModuleType("comfy.sd") + state = FakeComfyState() + + def load_checkpoint_guess_config( + ckpt_path: str, + output_vae: bool, + output_clip: bool, + embedding_directory: list[str], + ) -> tuple[object, object, object, object]: + """Record checkpoint load calls and return fixed outputs.""" + + state.checkpoint_calls.append( + { + "ckpt_path": ckpt_path, + "output_vae": output_vae, + "output_clip": output_clip, + "embedding_directory": embedding_directory, + } + ) + return ( + "checkpoint_model", + state.checkpoint_clip, + "checkpoint_vae", + "ignored_clipvision", + ) + + comfy_sd.load_checkpoint_guess_config = load_checkpoint_guess_config # type: ignore[attr-defined] + comfy_module.sd = comfy_sd # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "comfy", comfy_module) + monkeypatch.setitem(sys.modules, "comfy.sd", comfy_sd) + return state diff --git a/tests/test_conditioning_batch.py b/tests/test_conditioning_batch.py new file mode 100644 index 0000000..633dba2 --- /dev/null +++ b/tests/test_conditioning_batch.py @@ -0,0 +1,79 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for SimpleSyrup conditioning batch domain behavior.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.conditioning_batch import ( + ConditioningBatch, + select_conditioning, + split_prompt_batch, +) + + +def test_split_prompt_batch_without_separator_returns_single_chunk() -> None: + """Plain prompt text remains one prompt entry.""" + + assert split_prompt_batch("cat", "[SEP]") == ("cat",) + + +def test_split_prompt_batch_trims_separator_whitespace() -> None: + """Whitespace around separators does not become prompt text.""" + + assert split_prompt_batch("cat [SEP] dog", "[SEP]") == ("cat", "dog") + assert split_prompt_batch("cat[SEP]dog", "[SEP]") == ("cat", "dog") + + +def test_split_prompt_batch_preserves_blank_prompt_and_empty_chunks() -> None: + """Blank prompts and trailing separator chunks remain explicit entries.""" + + assert split_prompt_batch("", "[SEP]") == ("",) + assert split_prompt_batch("cat [SEP]", "[SEP]") == ("cat", "") + + +def test_split_prompt_batch_rejects_empty_separator() -> None: + """An empty separator would split between every character.""" + + with pytest.raises(ValueError, match="separator must not be empty"): + split_prompt_batch("cat", "") + + +def test_conditioning_batch_requires_entries() -> None: + """A batch must contain at least one selectable entry.""" + + with pytest.raises(ValueError, match="conditioning batch must contain"): + ConditioningBatch(()) + + +def test_conditioning_batch_selects_by_index_with_last_entry_fallback() -> None: + """Indexes beyond the batch length reuse the final entry.""" + + assert ConditioningBatch(("a",)).select(0) == "a" + assert ConditioningBatch(("a",)).select(5) == "a" + assert ConditioningBatch(("a", "b")).select(1) == "b" + assert ConditioningBatch(("a", "b")).select(5) == "b" + + +def test_conditioning_batch_rejects_negative_indexes() -> None: + """Negative indexes are invalid for per-SEG selection.""" + + with pytest.raises(ValueError, match="conditioning batch index"): + ConditioningBatch(("a",)).select(-1) + + +def test_select_conditioning_broadcasts_normal_conditioning() -> None: + """Normal conditionings pass through unchanged for any valid index.""" + + conditioning = object() + + assert select_conditioning(conditioning, 3) is conditioning + + +def test_select_conditioning_uses_batch_fallback() -> None: + """Batch selection uses the same last-entry fallback policy.""" + + assert select_conditioning(ConditioningBatch(("a", "b")), 5) == "b" diff --git a/tests/test_conditioning_batch_pack_node.py b/tests/test_conditioning_batch_pack_node.py new file mode 100644 index 0000000..7c90cb5 --- /dev/null +++ b/tests/test_conditioning_batch_pack_node.py @@ -0,0 +1,61 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for conditioning batch pack nodes.""" + +from __future__ import annotations + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.nodes.conditioning_batch_pack import ( + ConditioningBatchAppend, + ConditioningBatchStart, +) + + +def test_conditioning_batch_start_contract() -> None: + """Start node exposes the internal batch output contract.""" + + inputs = ConditioningBatchStart.INPUT_TYPES() + + assert ConditioningBatchStart.RETURN_TYPES == ("CONDITIONING_BATCH",) + assert ConditioningBatchStart.RETURN_NAMES == ("batch",) + assert ConditioningBatchStart.CATEGORY == "SimpleSyrup/Conditioning" + assert list(inputs["required"]) == ["conditioning"] + assert inputs["required"]["conditioning"][0] == "CONDITIONING" + + +def test_conditioning_batch_start_packs_one_entry() -> None: + """Start node wraps one conditioning in a new batch.""" + + conditioning = object() + + (batch,) = ConditioningBatchStart().pack(conditioning) + + assert batch == ConditioningBatch((conditioning,)) + + +def test_conditioning_batch_append_contract() -> None: + """Append node exposes the internal batch extension contract.""" + + inputs = ConditioningBatchAppend.INPUT_TYPES() + + assert ConditioningBatchAppend.RETURN_TYPES == ("CONDITIONING_BATCH",) + assert ConditioningBatchAppend.RETURN_NAMES == ("batch",) + assert ConditioningBatchAppend.CATEGORY == "SimpleSyrup/Conditioning" + assert list(inputs["required"]) == ["batch", "conditioning"] + assert inputs["required"]["batch"][0] == "CONDITIONING_BATCH" + assert inputs["required"]["conditioning"][0] == "CONDITIONING" + + +def test_conditioning_batch_append_returns_new_batch() -> None: + """Append node does not mutate the existing batch.""" + + first = object() + second = object() + existing = ConditioningBatch((first,)) + + (extended,) = ConditioningBatchAppend().append(existing, second) + + assert existing.entries == (first,) + assert extended.entries == (first, second) diff --git a/tests/test_detail_geometry.py b/tests/test_detail_geometry.py new file mode 100644 index 0000000..6cc5341 --- /dev/null +++ b/tests/test_detail_geometry.py @@ -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 + +"""Tests for scale-factor detail geometry.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.detail_geometry import ( + DetailScalePlan, + build_detail_scale_plan, +) + + +def test_clamp_zero_leaves_scaled_size_unclamped() -> None: + """A clamp of zero means no maximum crop size.""" + + plan = build_detail_scale_plan(20, 10, 100, 50, 1.5, 0) + + assert plan == DetailScalePlan( + width=150, + height=75, + scale=1.5, + unclamped_long_side=150.0, + target_long_side=150.0, + ) + + +def test_positive_clamp_caps_long_side() -> None: + """A positive clamp limits the scaled crop long side.""" + + plan = build_detail_scale_plan(20, 10, 100, 50, 2.0, 120) + + assert plan.width == 120 + assert plan.height == 60 + assert plan.scale == 1.2 + + +def test_scale_uses_crop_size_not_raw_detection_size() -> None: + """The crop/detail region controls scale-factor geometry.""" + + plan = build_detail_scale_plan(10, 10, 100, 50, 2.0, 0) + + assert plan.width == 200 + assert plan.height == 100 + + +def test_small_regions_do_not_produce_zero_size() -> None: + """Tiny values still produce usable pixel dimensions.""" + + plan = build_detail_scale_plan(1, 1, 1, 1, 0.1, 0) + + assert plan.width == 1 + assert plan.height == 1 + + +@pytest.mark.parametrize( + ("scale_factor", "clamp_size", "message"), + [ + (0.0, 0, "scale_factor must be greater than 0"), + (1.0, -1, "clamp_size must be 0 or greater"), + ], +) +def test_invalid_scale_settings_raise_value_error( + scale_factor: float, + clamp_size: int, + message: str, +) -> None: + """Invalid scale settings fail before detail work begins.""" + + with pytest.raises(ValueError, match=message): + build_detail_scale_plan(1, 1, 8, 8, scale_factor, clamp_size) diff --git a/tests/test_detail_previews.py b/tests/test_detail_previews.py new file mode 100644 index 0000000..1506ce8 --- /dev/null +++ b/tests/test_detail_previews.py @@ -0,0 +1,349 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for full-context detailer preview composition.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +import torch +from PIL import Image + +from simple_syrup.domain.segs import CropRegion +from simple_syrup.runtime import detail_previews +from simple_syrup.runtime.detail_previews import ( + DETAIL_PREVIEW_OUTLINE_RGB, + DetailPreviewCompositor, + DetailPreviewContext, + build_detail_preview_geometry, + fit_preview_size, + prepare_detail_preview_callback, + work_region_from_mask, +) + + +def test_fit_preview_size_preserves_small_dimensions() -> None: + """Images already inside the cap keep their original dimensions.""" + + assert fit_preview_size(100, 50, 512) == (100, 50) + + +def test_fit_preview_size_caps_long_side() -> None: + """Large images are proportionally capped to the preview size.""" + + assert fit_preview_size(1000, 500, 200) == (200, 100) + assert fit_preview_size(500, 1000, 200) == (100, 200) + + +def test_preview_geometry_maps_crop_and_outline_boxes() -> None: + """Preview geometry maps normal crops with an outside outline.""" + + geometry = build_detail_preview_geometry( + source_width=8, + source_height=8, + crop_region=CropRegion(2, 1, 6, 5), + max_preview_resolution=8, + ) + + assert geometry.source_size == (8, 8) + assert geometry.preview_size == (8, 8) + assert geometry.crop_box == (2, 1, 6, 5) + assert geometry.outline_box == (1, 0, 6, 5) + + +def test_preview_geometry_keeps_edge_outline_inside_image() -> None: + """Outlines fall inside the image when a crop touches image edges.""" + + geometry = build_detail_preview_geometry( + source_width=8, + source_height=8, + crop_region=CropRegion(0, 0, 8, 8), + max_preview_resolution=8, + ) + + assert geometry.crop_box == (0, 0, 8, 8) + assert geometry.outline_box == (0, 0, 7, 7) + + +def test_preview_geometry_handles_downscaled_tiny_crop() -> None: + """Heavily downscaled previews still produce valid crop boxes.""" + + geometry = build_detail_preview_geometry( + source_width=200, + source_height=100, + crop_region=CropRegion(50, 25, 51, 26), + max_preview_resolution=50, + ) + + assert geometry.preview_size == (50, 25) + assert geometry.crop_box[2] > geometry.crop_box[0] + assert geometry.crop_box[3] > geometry.crop_box[1] + assert geometry.outline_box[2] >= geometry.outline_box[0] + assert geometry.outline_box[3] >= geometry.outline_box[1] + + +def test_compositor_darkens_background_pastes_crop_and_draws_outline() -> None: + """Composed previews darken context, paste the crop, and draw the outline.""" + + context = DetailPreviewContext( + image=torch.ones((1, 8, 8, 3), dtype=torch.float32), + work_region=CropRegion(2, 2, 6, 6), + work_mask=torch.ones((4, 4), dtype=torch.float32), + ) + compositor = DetailPreviewCompositor.from_context( + context, + max_preview_resolution=8, + ) + + output = compositor.compose(Image.new("RGB", (4, 4), (0, 255, 0))) + + assert output.size == (8, 8) + assert output.getpixel((0, 0)) != (255, 255, 255) + assert output.getpixel((3, 3)) == (0, 255, 0) + assert output.getpixel((1, 1)) == DETAIL_PREVIEW_OUTLINE_RGB + + +def test_compositor_keeps_unmasked_crop_area_washed() -> None: + """Only masked pixels inside the crop reveal the active crop preview.""" + + crop_mask = torch.zeros((4, 4), dtype=torch.float32) + crop_mask[1:3, 1:3] = 1.0 + context = DetailPreviewContext( + image=torch.ones((1, 8, 8, 3), dtype=torch.float32), + work_region=CropRegion(2, 2, 6, 6), + work_mask=crop_mask, + ) + compositor = DetailPreviewCompositor.from_context( + context, + max_preview_resolution=8, + ) + + output = compositor.compose(Image.new("RGB", (4, 4), (0, 255, 0))) + + assert output.getpixel((3, 3)) == (0, 255, 0) + assert output.getpixel((2, 2)) != (0, 255, 0) + assert output.getpixel((2, 2)) != (255, 255, 255) + + +def test_compositor_uses_preview_size_for_large_images() -> None: + """Full-context composition uses preview resolution instead of source size.""" + + context = DetailPreviewContext( + image=torch.ones((1, 100, 200, 3), dtype=torch.float32), + work_region=CropRegion(50, 25, 150, 75), + work_mask=torch.ones((50, 100), dtype=torch.float32), + ) + compositor = DetailPreviewCompositor.from_context( + context, + max_preview_resolution=50, + ) + + assert compositor.washed_background.size == (50, 25) + + +def test_full_image_mask_compositor_reveals_only_work_mask() -> None: + """Full-canvas previews are revealed only through the provided work mask.""" + + work_mask = torch.zeros((8, 8), dtype=torch.float32) + work_mask[2:6, 3:7] = 1.0 + context = DetailPreviewContext( + image=torch.ones((1, 8, 8, 3), dtype=torch.float32), + work_region=work_region_from_mask(work_mask), + work_mask=work_mask, + sampled_region=CropRegion(0, 0, 8, 8), + ) + compositor = DetailPreviewCompositor.from_context( + context, + max_preview_resolution=8, + ) + + output = compositor.compose(Image.new("RGB", (8, 8), (0, 255, 0))) + + assert output.getpixel((4, 3)) == (0, 255, 0) + assert output.getpixel((0, 0)) != (0, 255, 0) + assert output.getpixel((2, 1)) == DETAIL_PREVIEW_OUTLINE_RGB + + +def test_work_region_from_mask_returns_tight_bounds() -> None: + """Work regions are the tight bounds around non-empty masks.""" + + mask = torch.zeros((1, 8, 8), dtype=torch.float32) + mask[:, 2:6, 3:7] = 1.0 + + assert work_region_from_mask(mask) == CropRegion(3, 2, 7, 6) + + +def test_work_region_from_empty_mask_fails() -> None: + """Empty work masks fail before a confusing preview is produced.""" + + with pytest.raises(ValueError, match="work mask"): + work_region_from_mask(torch.zeros((8, 8), dtype=torch.float32)) + + +def test_prepare_detail_preview_callback_sends_composed_preview( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A previewer-backed callback sends a composed full-context image.""" + + progress = _ProgressRecorder() + monkeypatch.setattr( + detail_previews, + "_latent_preview", + lambda: _LatentPreviewModule(_Previewer(), 8), + ) + monkeypatch.setattr( + detail_previews, + "_comfy_utils", + lambda: _ComfyUtilsModule(progress), + ) + context = DetailPreviewContext( + image=torch.ones((1, 8, 8, 3), dtype=torch.float32), + work_region=CropRegion(2, 2, 6, 6), + work_mask=torch.ones((4, 4), dtype=torch.float32), + ) + + callback = prepare_detail_preview_callback(_Model(), 4, context) + callback(0, torch.zeros((1, 4, 4, 4)), torch.zeros((1, 4, 4, 4)), 4) + + assert progress.updates[0].value == 1 + assert progress.updates[0].total == 4 + preview = progress.updates[0].preview + assert preview is not None + assert preview[0] == "JPEG" + assert preview[1].size == (8, 8) + assert preview[2] == 8 + + +def test_prepare_detail_preview_callback_without_previewer_skips_compositor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A disabled ComfyUI previewer updates progress without composition.""" + + progress = _ProgressRecorder() + monkeypatch.setattr( + detail_previews, + "_latent_preview", + lambda: _LatentPreviewModule(None, 8), + ) + monkeypatch.setattr( + detail_previews, + "_comfy_utils", + lambda: _ComfyUtilsModule(progress), + ) + monkeypatch.setattr( + DetailPreviewCompositor, + "from_context", + _raise_if_compositor_is_built, + ) + context = DetailPreviewContext( + image=torch.ones((1, 8, 8, 3), dtype=torch.float32), + work_region=CropRegion(2, 2, 6, 6), + work_mask=torch.ones((4, 4), dtype=torch.float32), + ) + + callback = prepare_detail_preview_callback(_Model(), 4, context) + callback(1, torch.zeros((1, 4, 4, 4)), torch.zeros((1, 4, 4, 4)), 4) + + assert progress.updates == [_ProgressUpdate(2, 4, None)] + + +@dataclass(frozen=True) +class _ProgressUpdate: + """Record one preview progress update.""" + + value: int + total: int + preview: detail_previews.PreviewBytes | None + + +class _ProgressRecorder: + """Record progress updates from a callback under test.""" + + def __init__(self) -> None: + """Create an empty update list.""" + + self.updates: list[_ProgressUpdate] = [] + + def update_absolute( + self, + value: int, + total: int, + preview: detail_previews.PreviewBytes | None = None, + ) -> None: + """Record one absolute progress update.""" + + self.updates.append(_ProgressUpdate(value, total, preview)) + + +class _ComfyUtilsModule: + """Fake ComfyUI utils module for callback tests.""" + + def __init__(self, progress: _ProgressRecorder) -> None: + """Store the progress recorder returned by ProgressBar.""" + + self._progress = progress + + def ProgressBar(self, steps: int) -> _ProgressRecorder: + """Return the shared progress recorder.""" + + del steps + return self._progress + + +class _LatentPreviewModule: + """Fake latent preview module for callback tests.""" + + MAX_PREVIEW_RESOLUTION: int + + def __init__(self, previewer: _Previewer | None, max_resolution: int) -> None: + """Store previewer lookup results.""" + + self._previewer = previewer + self.MAX_PREVIEW_RESOLUTION = max_resolution + + def get_previewer( + self, + device: object, + latent_format: object, + ) -> _Previewer | None: + """Return the configured fake previewer.""" + + del device, latent_format + return self._previewer + + +class _Previewer: + """Fake latent previewer returning a deterministic crop image.""" + + def decode_latent_to_preview(self, x0: torch.Tensor) -> Image.Image: + """Return a green crop preview.""" + + del x0 + return Image.new("RGB", (4, 4), (0, 255, 0)) + + +class _InnerModel: + """Fake inner ComfyUI model carrying latent format.""" + + latent_format = object() + + +class _Model: + """Fake ComfyUI model carrying previewer lookup attributes.""" + + load_device = object() + model = _InnerModel() + + +def _raise_if_compositor_is_built( + cls: type[DetailPreviewCompositor], + context: DetailPreviewContext, + max_preview_resolution: int, +) -> DetailPreviewCompositor: + """Fail tests if disabled previews try to build composition state.""" + + del cls, context, max_preview_resolution + raise AssertionError("compositor should not be built without a previewer") diff --git a/tests/test_detail_resize.py b/tests/test_detail_resize.py new file mode 100644 index 0000000..f8c23f4 --- /dev/null +++ b/tests/test_detail_resize.py @@ -0,0 +1,137 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for detailer image resize routing.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.runtime.detail_resize import ( + TORCHLANC_PRECISION, + TORCHLANC_SINC_WINDOW, + DetailImageResizer, +) + + +def test_non_lanczos_upscale_uses_gpu_native_resampler() -> None: + """Detail upscaling routes non-Lanczos methods through native GPU resize.""" + + native = _FakeNativeResampler() + lanczos = _FakeLanczosResampler() + image = torch.ones((1, 3, 5, 2), dtype=torch.float32) + + output = DetailImageResizer( + native_resampler_factory=lambda: native, + lanczos_resampler_factory=lambda: lanczos, + ).resize_up(image, 6, 8, "bicubic") + + assert output.shape == (1, 6, 8, 2) + assert native.calls == [((1, 2, 3, 5), 8, 6, "bicubic")] + assert lanczos.calls == [] + + +def test_lanczos_upscale_uses_torchlanc_resampler() -> None: + """Detail upscaling maps Lanczos to TorchLanc.""" + + native = _FakeNativeResampler() + lanczos = _FakeLanczosResampler() + + output = DetailImageResizer( + native_resampler_factory=lambda: native, + lanczos_resampler_factory=lambda: lanczos, + ).resize_up(torch.ones((1, 3, 5, 2)), 7, 9, "lanczos") + + assert output.shape == (1, 7, 9, 2) + assert native.calls == [] + assert lanczos.calls == [ + ((1, 2, 3, 5), 9, 7, TORCHLANC_SINC_WINDOW, TORCHLANC_PRECISION) + ] + + +def test_downscale_always_uses_torchlanc_lanczos() -> None: + """Detail downscaling ignores native resize and uses fixed TorchLanc settings.""" + + native = _FakeNativeResampler() + lanczos = _FakeLanczosResampler() + + output = DetailImageResizer( + native_resampler_factory=lambda: native, + lanczos_resampler_factory=lambda: lanczos, + ).resize_down_lanczos(torch.ones((1, 9, 11, 3)), 4, 6) + + assert output.shape == (1, 4, 6, 3) + assert native.calls == [] + assert lanczos.calls == [ + ((1, 3, 9, 11), 6, 4, TORCHLANC_SINC_WINDOW, TORCHLANC_PRECISION) + ] + + +def test_unsupported_upscale_method_fails_clearly() -> None: + """Unsupported detailer upscale methods fail before resize side effects.""" + + with pytest.raises(ValueError, match="Unsupported sampling method"): + DetailImageResizer().resize_up(torch.ones((1, 3, 5, 2)), 6, 8, "box") + + +class _FakeNativeResampler: + """Record native resize calls and return shaped tensors.""" + + def __init__(self) -> None: + """Create empty call storage.""" + + self.calls: list[tuple[tuple[int, ...], int, int, str]] = [] + + def resize( + self, + samples: torch.Tensor, + width: int, + height: int, + sampling: str, + ) -> torch.Tensor: + """Record the call and return a resized placeholder.""" + + self.calls.append( + (tuple(int(dim) for dim in samples.shape), width, height, sampling) + ) + return torch.full( + (int(samples.shape[0]), int(samples.shape[1]), height, width), + 0.25, + dtype=samples.dtype, + ) + + +class _FakeLanczosResampler: + """Record TorchLanc resize calls and return shaped tensors.""" + + def __init__(self) -> None: + """Create empty call storage.""" + + self.calls: list[tuple[tuple[int, ...], int, int, int, str]] = [] + + def resize( + self, + samples: torch.Tensor, + width: int, + height: int, + sinc_window: int, + precision: str, + ) -> torch.Tensor: + """Record the call and return a resized placeholder.""" + + self.calls.append( + ( + tuple(int(dim) for dim in samples.shape), + width, + height, + sinc_window, + precision, + ) + ) + return torch.full( + (int(samples.shape[0]), int(samples.shape[1]), height, width), + 0.75, + dtype=samples.dtype, + ) diff --git a/tests/test_detail_segs_as_regions_node.py b/tests/test_detail_segs_as_regions_node.py new file mode 100644 index 0000000..ffa07a6 --- /dev/null +++ b/tests/test_detail_segs_as_regions_node.py @@ -0,0 +1,341 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Detail SEGS as Regions node contract.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.nodes.detail_segs_as_regions import DetailSEGSAsRegions +from simple_syrup.runtime import sampling_samplers, sampling_schedulers +from simple_syrup.services.detail_segs_as_regions_service import ( + DetailSEGSAsRegionsResult, +) + + +def test_detail_segs_as_regions_node_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regional detailer node exposes its planned ComfyUI contract.""" + + monkeypatch.setattr(sampling_samplers, "available_samplers", lambda: ("euler",)) + monkeypatch.setattr( + sampling_schedulers, + "available_schedulers", + lambda: ("normal",), + ) + inputs = DetailSEGSAsRegions.INPUT_TYPES() + + assert DetailSEGSAsRegions.RETURN_TYPES == ("IMAGE",) + assert DetailSEGSAsRegions.RETURN_NAMES == ("image",) + assert DetailSEGSAsRegions.FUNCTION == "detail" + assert DetailSEGSAsRegions.CATEGORY == "SimpleSyrup/Detailing" + assert DetailSEGSAsRegions.INPUT_IS_LIST is True + assert list(inputs["required"]) == [ + "image", + "model", + "vae", + "negative", + "positive", + "segs", + "region_positive", + "global_prompt_weight", + "scale_factor", + "upscale_method", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + "feather", + "noise_mask", + "noise_mask_feather", + "tiled_encode", + "tiled_decode", + ] + assert inputs["required"]["region_positive"][0] == "CONDITIONING_BATCH" + assert inputs["required"]["global_prompt_weight"][1]["default"] == 0.25 + assert inputs["required"]["global_prompt_weight"][1]["min"] == 0.0 + assert inputs["required"]["global_prompt_weight"][1]["max"] == 1.0 + scale_factor_options = inputs["required"]["scale_factor"][1] + assert scale_factor_options["default"] == 1.0 + assert scale_factor_options["min"] == 1.0 + assert scale_factor_options["max"] == 5.0 + assert scale_factor_options["step"] == 0.1 + assert inputs["required"]["upscale_method"][1]["default"] == "lanczos" + assert inputs["required"]["noise_mask"][1]["default"] is True + + +def test_detail_segs_as_regions_node_delegates_all_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regional detailer forwards normalized values to the service.""" + + fake_service = _FakeRegionalDetailerService() + monkeypatch.setattr( + DetailSEGSAsRegions, + "service_class", + staticmethod(lambda: fake_service), + ) + image = torch.zeros((1, 8, 8, 3), dtype=torch.float32) + region_positive = ConditioningBatch((["region"],)) + + (output_image,) = DetailSEGSAsRegions().detail( + image, + model="model", + vae="vae", + negative=["negative"], + positive=["positive"], + segs=_segs(), + region_positive=region_positive, + global_prompt_weight=0.25, + scale_factor=2.0, + upscale_method="bicubic", + seed=1, + steps=2, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + denoise=0.5, + feather=5, + noise_mask=True, + noise_mask_feather=20, + tiled_encode=False, + tiled_decode=True, + ) + + assert torch.equal(cast(torch.Tensor, output_image), image + 1.0) + assert len(fake_service.calls) == 1 + call = fake_service.calls[0] + assert torch.equal(cast(torch.Tensor, call["image"]), image) + call_segs = cast(NativeSegs, call["segs"]) + assert call_segs[0] == (8, 8) + assert call_segs[1][0].label == "face" + assert torch.equal( + cast(torch.Tensor, call_segs[1][0].cropped_mask), torch.ones((2, 2)) + ) + assert call["model"] == "model" + assert call["vae"] == "vae" + assert call["positive"] == ["positive"] + assert call["negative"] == ["negative"] + assert call["region_positive"] == region_positive + assert call["global_prompt_weight"] == 0.25 + assert call["scale_factor"] == 2.0 + assert call["upscale_method"] == "bicubic" + assert call["seed"] == 1 + assert call["steps"] == 2 + assert call["cfg"] == 7.0 + assert call["sampler_name"] == "euler" + assert call["scheduler"] == "normal" + assert call["denoise"] == 0.5 + assert call["feather"] == 5 + assert call["noise_mask"] is True + assert call["noise_mask_feather"] == 20 + assert call["tiled_encode"] is False + assert call["tiled_decode"] is True + + +def test_detail_segs_as_regions_node_pairs_image_batch_segs_and_region_positive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """List-mode region_positive values are paired one-to-one with SEGS.""" + + fake_service = _FakeRegionalDetailerService() + monkeypatch.setattr( + DetailSEGSAsRegions, + "service_class", + staticmethod(lambda: fake_service), + ) + image = torch.zeros((2, 8, 8, 3), dtype=torch.float32) + first_region_positive = ConditioningBatch((["first"],)) + second_region_positive = ConditioningBatch((["second"],)) + + (output_image,) = DetailSEGSAsRegions().detail( + image=[image], + model=["model"], + vae=["vae"], + negative=[["negative"]], + positive=[["positive"]], + segs=[_segs("first"), _segs("second")], + region_positive=[first_region_positive, second_region_positive], + global_prompt_weight=[0.4], + scale_factor=[2.0], + upscale_method=["area"], + seed=[1], + steps=[2], + cfg=[7.0], + sampler_name=["euler"], + scheduler=["normal"], + denoise=[0.5], + feather=[5], + noise_mask=[True], + noise_mask_feather=[20], + tiled_encode=[False], + tiled_decode=[True], + ) + + assert torch.equal(cast(torch.Tensor, output_image), image + 1.0) + assert [call["region_positive"] for call in fake_service.calls] == [ + first_region_positive, + second_region_positive, + ] + labels = [cast(NativeSegs, call["segs"])[1][0].label for call in fake_service.calls] + assert labels == ["first", "second"] + + +def test_detail_segs_as_regions_node_rejects_broadcast_region_positive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Multi-image list mode requires one regional batch per SEGS payload.""" + + monkeypatch.setattr( + DetailSEGSAsRegions, + "service_class", + _FakeRegionalDetailerService, + ) + + with pytest.raises(ValueError, match="received 1 conditioning batches for 2"): + DetailSEGSAsRegions().detail( + image=[torch.zeros((2, 8, 8, 3), dtype=torch.float32)], + model=["model"], + vae=["vae"], + negative=[["negative"]], + positive=[["positive"]], + segs=[_segs("first"), _segs("second")], + region_positive=[ConditioningBatch((["only"],))], + global_prompt_weight=[0.25], + scale_factor=[1.0], + upscale_method=["lanczos"], + seed=[1], + steps=[2], + cfg=[7.0], + sampler_name=["euler"], + scheduler=["normal"], + denoise=[0.5], + feather=[5], + noise_mask=[True], + noise_mask_feather=[20], + tiled_encode=[False], + tiled_decode=[True], + ) + + +def test_detail_segs_as_regions_node_rejects_image_segs_mismatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regional detailer mirrors existing image and SEGS pairing validation.""" + + monkeypatch.setattr( + DetailSEGSAsRegions, + "service_class", + _FakeRegionalDetailerService, + ) + + with pytest.raises(ValueError, match="received 2 images and 1 SEGS payload"): + DetailSEGSAsRegions().detail( + image=[torch.zeros((2, 8, 8, 3), dtype=torch.float32)], + model=["model"], + vae=["vae"], + negative=[["negative"]], + positive=[["positive"]], + segs=[_segs()], + region_positive=[ConditioningBatch((["region"],))], + global_prompt_weight=[0.25], + scale_factor=[1.0], + upscale_method=["lanczos"], + seed=[1], + steps=[2], + cfg=[7.0], + sampler_name=["euler"], + scheduler=["normal"], + denoise=[0.5], + feather=[5], + noise_mask=[True], + noise_mask_feather=[20], + tiled_encode=[False], + tiled_decode=[True], + ) + + +class _FakeRegionalDetailerService: + """Fake regional detailer service for node tests.""" + + def __init__(self) -> None: + """Create a call-recording fake service.""" + + self.calls: list[dict[str, object]] = [] + + def detail( + self, + image: object, + segs: object, + model: Any, + vae: Any, + positive: Any, + negative: Any, + region_positive: object, + global_prompt_weight: float, + scale_factor: float, + upscale_method: str, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + denoise: float, + feather: int, + noise_mask: bool, + noise_mask_feather: int, + tiled_encode: bool, + tiled_decode: bool, + ) -> DetailSEGSAsRegionsResult: + """Return deterministic detailer output and record the call.""" + + self.calls.append( + { + "image": image, + "segs": segs, + "model": model, + "vae": vae, + "positive": positive, + "negative": negative, + "region_positive": region_positive, + "global_prompt_weight": global_prompt_weight, + "scale_factor": scale_factor, + "upscale_method": upscale_method, + "seed": seed, + "steps": steps, + "cfg": cfg, + "sampler_name": sampler_name, + "scheduler": scheduler, + "denoise": denoise, + "feather": feather, + "noise_mask": noise_mask, + "noise_mask_feather": noise_mask_feather, + "tiled_encode": tiled_encode, + "tiled_decode": tiled_decode, + } + ) + return DetailSEGSAsRegionsResult(image=cast(torch.Tensor, image) + 1.0) + + +def _segs(label: str = "face") -> NativeSegs: + """Return native SEGS for node tests.""" + + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((2, 2)), + confidence=1.0, + crop_region=CropRegion(0, 0, 2, 2), + bbox=BoundingBox(0, 0, 2, 2), + label=label, + ) + return (8, 8), (segment,) diff --git a/tests/test_detail_segs_as_regions_service.py b/tests/test_detail_segs_as_regions_service.py new file mode 100644 index 0000000..3a5edb7 --- /dev/null +++ b/tests/test_detail_segs_as_regions_service.py @@ -0,0 +1,596 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Detail SEGS as Regions service.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.regional_detailing import LatentRegion +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.runtime.detail_previews import DetailPreviewContext +from simple_syrup.runtime.detail_sampling import Latent +from simple_syrup.services.detail_segs_as_regions_service import ( + DetailSEGSAsRegionsService, + RegionalDetailResizeBoundary, + RegionalDetailSamplingBoundary, +) + + +def test_empty_segs_return_original_image_without_sampling() -> None: + """No SEGS regions leave the input image unchanged.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + image = _image() + + result = service.detail( + image, + ((8, 8), ()), + object(), + object(), + [], + [], + object(), + **_settings(), + ) + + assert torch.equal(result.image, image) + assert sampler.encoded_shapes == [] + assert sampler.sample_calls == [] + + +def test_mismatched_region_conditioning_count_fails_before_encode() -> None: + """Regional prompts must pair exactly with SEGS.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + with pytest.raises(ValueError, match="1 conditioning entries for 2 SEGS"): + service.detail( + _image(), + _segs(_segment(), _segment(CropRegion(4, 4, 8, 8))), + object(), + object(), + [], + [], + ConditioningBatch(("only one",)), + **_settings(), + ) + + assert sampler.encoded_shapes == [] + + +def test_full_image_encoded_once_and_decoded_once() -> None: + """Regional detailing samples one full latent instead of per-crop latents.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + result = service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **_settings(), + ) + + assert result.image.shape == (1, 8, 8, 3) + assert sampler.encoded_shapes == [(1, 8, 8, 3)] + assert sampler.decode_count == 1 + assert len(sampler.sample_calls) == 1 + + +def test_regions_are_passed_to_runtime_in_original_order() -> None: + """SEGS and conditioning remain paired through service orchestration.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + first = _segment(CropRegion(0, 0, 4, 4), label="first") + second = _segment(CropRegion(4, 4, 8, 8), label="second") + + service.detail( + _image(), + _segs(first, second), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("positive 1", "positive 2")), + **_settings(), + ) + + regions = sampler.sample_calls[0]["regions"] + assert [region.index for region in regions] == [0, 1] + assert [region.label for region in regions] == ["first", "second"] + assert [region.positive for region in regions] == ["positive 1", "positive 2"] + + +def test_decoded_output_is_composited_only_inside_union_mask() -> None: + """Final compositing preserves original pixels outside SEGS masks.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + result = service.detail( + _image(), + _segs(_segment(CropRegion(0, 0, 4, 4))), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **(_settings() | {"feather": 0}), + ) + + assert torch.all(result.image[:, :4, :4, :] == 1.0) + assert torch.all(result.image[:, 4:, :, :] == 0.0) + assert torch.all(result.image[:, :, 4:, :] == 0.0) + + +def test_scale_factor_resizes_working_canvas_and_preserves_outside_mask() -> None: + """Scaled regional detailing keeps original pixels outside the union mask.""" + + sampler = _FakeRegionalSampler() + image_resizer = _FakeImageResizer() + service = _service(sampler, image_resizer) + image = _image() + 0.25 + + result = service.detail( + image, + _segs(_segment(CropRegion(0, 0, 4, 4))), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **(_settings() | {"scale_factor": 2.0, "upscale_method": "bicubic"}), + ) + + assert sampler.encoded_shapes == [(1, 16, 16, 3)] + assert image_resizer.upscale_calls == [((1, 8, 8, 3), 16, 16, "bicubic")] + assert image_resizer.downscale_calls == [((1, 16, 16, 3), 8, 8)] + assert torch.all(result.image[:, :4, :4, :] == 1.0) + assert torch.all(result.image[:, 4:, :, :] == 0.25) + assert torch.all(result.image[:, :, 4:, :] == 0.25) + + +def test_regional_detailing_forwards_union_preview_context() -> None: + """Regional sampling previews use original-resolution union-mask context.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment(CropRegion(2, 1, 6, 5))), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **_settings(), + ) + + preview_context = sampler.sample_calls[0]["preview_context"] + assert isinstance(preview_context, DetailPreviewContext) + assert preview_context.work_region == CropRegion(2, 1, 6, 5) + assert preview_context.sampled_region == CropRegion(0, 0, 8, 8) + assert preview_context.work_mask.shape == (8, 8) + + +def test_scale_factor_at_or_below_one_samples_original_size() -> None: + """Regional scale factors below one do not downscale the working image.""" + + sampler = _FakeRegionalSampler() + image_resizer = _FakeImageResizer() + service = _service(sampler, image_resizer) + + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **(_settings() | {"scale_factor": 0.5}), + ) + + assert sampler.encoded_shapes == [(1, 8, 8, 3)] + assert image_resizer.upscale_calls == [] + assert image_resizer.downscale_calls == [] + + +def test_invalid_scale_factor_fails_before_encode() -> None: + """Scale factor must be positive before runtime side effects.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + with pytest.raises(ValueError, match="scale_factor"): + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **(_settings() | {"scale_factor": 0.0}), + ) + + assert sampler.encoded_shapes == [] + + +def test_noise_mask_true_attaches_latent_union_mask() -> None: + """Noise-mask mode passes a latent mask to regional sampling.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **_settings(), + ) + + latent_image = sampler.sample_calls[0]["latent_image"] + assert latent_image["noise_mask"].shape == (1, 4, 4) + + +def test_noise_mask_false_omits_latent_mask_but_composites_pixels() -> None: + """Disabling denoise masks still preserves pixels outside region composite.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + result = service.detail( + _image(), + _segs(_segment(CropRegion(0, 0, 4, 4))), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **(_settings() | {"noise_mask": False, "feather": 0}), + ) + + assert "noise_mask" not in sampler.sample_calls[0]["latent_image"] + assert torch.all(result.image[:, :4, :4, :] == 1.0) + assert torch.all(result.image[:, 4:, :, :] == 0.0) + + +def test_noise_mask_feather_applies_differential_diffusion() -> None: + """Feathered denoise masks request differential diffusion patching.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **(_settings() | {"noise_mask": True, "noise_mask_feather": 2}), + ) + + assert sampler.patch_count == 1 + assert sampler.sample_calls[0]["model"] == "patched model" + + +def test_encode_decode_and_sampler_controls_are_forwarded() -> None: + """Service forwards controls to runtime boundary unchanged.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **( + _settings() + | { + "tiled_encode": True, + "tiled_decode": True, + "seed": 999, + "steps": 12, + "cfg": 6.5, + "sampler_name": "dpmpp_2m", + "scheduler": "sgm_uniform", + "denoise": 0.35, + } + ), + ) + + assert sampler.encode_tiled == [True] + assert sampler.decode_tiled == [True] + call = sampler.sample_calls[0] + assert call["seed"] == 999 + assert call["steps"] == 12 + assert call["cfg"] == 6.5 + assert call["sampler_name"] == "dpmpp_2m" + assert call["scheduler"] == "sgm_uniform" + assert call["denoise"] == 0.35 + assert call["global_prompt_weight"] == 0.25 + + +def test_global_prompt_weight_is_forwarded_to_runtime() -> None: + """Service forwards the global/regional blend weight unchanged.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **(_settings() | {"global_prompt_weight": 0.6}), + ) + + assert sampler.sample_calls[0]["global_prompt_weight"] == 0.6 + + +def test_global_prompt_weight_out_of_range_fails_before_encode() -> None: + """Global prompt weight must stay in the normalized blend range.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + with pytest.raises(ValueError, match="global_prompt_weight"): + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **(_settings() | {"global_prompt_weight": 1.25}), + ) + + assert sampler.encoded_shapes == [] + + +def test_image_batch_fails_clearly() -> None: + """The service handles one image at a time.""" + + service = _service(_FakeRegionalSampler()) + + with pytest.raises(ValueError, match="supports one image at a time"): + service.detail( + torch.zeros((2, 8, 8, 3)), + _segs(_segment()), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **_settings(), + ) + + +class _FakeRegionalSampler: + """Fake VAE/sampling adapter for regional detailer tests.""" + + def __init__(self) -> None: + """Create empty call records.""" + + self.encoded_shapes: list[tuple[int, ...]] = [] + self.encode_tiled: list[bool] = [] + self.decode_tiled: list[bool] = [] + self.sample_calls: list[dict[str, Any]] = [] + self.decode_count = 0 + self.patch_count = 0 + + def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent: + """Record encoded pixel shape and return a latent.""" + + del vae + self.encoded_shapes.append(tuple(int(dim) for dim in pixels.shape)) + self.encode_tiled.append(tiled) + return { + "samples": torch.zeros( + ( + 1, + 4, + max(1, int(pixels.shape[1]) // 2), + max(1, int(pixels.shape[2]) // 2), + ), + dtype=torch.float32, + ) + } + + def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor: + """Return a bright full image matching the latent scale.""" + + del vae + self.decode_count += 1 + self.decode_tiled.append(tiled) + samples = latent["samples"] + if not isinstance(samples, torch.Tensor): + raise ValueError("latent samples must be a tensor.") + return torch.ones( + (1, int(samples.shape[-2]) * 2, int(samples.shape[-1]) * 2, 3), + dtype=torch.float32, + ) + + def sample_regions( + self, + *, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + regions: tuple[LatentRegion, ...], + denoise: float, + global_prompt_weight: float, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Record regional sample options and return the latent unchanged.""" + + self.sample_calls.append( + { + "model": model, + "seed": seed, + "steps": steps, + "cfg": cfg, + "sampler_name": sampler_name, + "scheduler": scheduler, + "positive": positive, + "negative": negative, + "latent_image": latent_image, + "regions": regions, + "denoise": denoise, + "global_prompt_weight": global_prompt_weight, + "preview_context": preview_context, + } + ) + return latent_image + + def apply_differential_diffusion(self, model: Any) -> Any: + """Record patching and return a sentinel model.""" + + del model + self.patch_count += 1 + return "patched model" + + +class _FakeImageResizer: + """Fake regional image resizer for service tests.""" + + def __init__(self) -> None: + """Create empty call records.""" + + self.upscale_calls: list[tuple[tuple[int, ...], int, int, str]] = [] + self.downscale_calls: list[tuple[tuple[int, ...], int, int]] = [] + + def resize_up( + self, + image: torch.Tensor, + height: int, + width: int, + method: str, + ) -> torch.Tensor: + """Record upscale options and return a shaped working image.""" + + self.upscale_calls.append( + (tuple(int(dim) for dim in image.shape), height, width, method) + ) + return torch.zeros((int(image.shape[0]), height, width, int(image.shape[3]))) + + def resize_down_lanczos( + self, + image: torch.Tensor, + height: int, + width: int, + ) -> torch.Tensor: + """Record downscale options and return a shaped detailed image.""" + + self.downscale_calls.append( + (tuple(int(dim) for dim in image.shape), height, width) + ) + return torch.ones((int(image.shape[0]), height, width, int(image.shape[3]))) + + +def _service( + sampler: _FakeRegionalSampler, + image_resizer: _FakeImageResizer | None = None, +) -> DetailSEGSAsRegionsService: + """Create a service with fake collaborators.""" + + return DetailSEGSAsRegionsService( + sampler=cast(RegionalDetailSamplingBoundary, sampler), + image_resizer=cast( + RegionalDetailResizeBoundary, + image_resizer or _FakeImageResizer(), + ), + ) + + +def _segs(*segments: Segment) -> NativeSegs: + """Return native SEGS for an 8x8 image.""" + + return (8, 8), tuple(segments) + + +def _segment( + crop_region: CropRegion | None = None, + bbox: BoundingBox | None = None, + label: str = "region", +) -> Segment: + """Return one test segment with a crop-local mask.""" + + resolved_region = crop_region or CropRegion(0, 0, 8, 8) + return Segment( + cropped_image=None, + cropped_mask=torch.ones((resolved_region.height, resolved_region.width)), + confidence=1.0, + crop_region=resolved_region, + bbox=bbox + or BoundingBox( + resolved_region.left, + resolved_region.top, + resolved_region.right, + resolved_region.bottom, + ), + label=label, + ) + + +def _image() -> torch.Tensor: + """Return a dark 8x8 image tensor.""" + + return torch.zeros((1, 8, 8, 3), dtype=torch.float32) + + +def _settings() -> dict[str, Any]: + """Return valid regional detailer settings for service tests.""" + + return { + "seed": 123, + "steps": 4, + "cfg": 7.0, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 0.5, + "feather": 0, + "noise_mask": True, + "noise_mask_feather": 0, + "tiled_encode": False, + "tiled_decode": False, + "global_prompt_weight": 0.25, + "scale_factor": 1.0, + "upscale_method": "lanczos", + } diff --git a/tests/test_detail_segs_by_scale_factor_node.py b/tests/test_detail_segs_by_scale_factor_node.py new file mode 100644 index 0000000..ca60c5d --- /dev/null +++ b/tests/test_detail_segs_by_scale_factor_node.py @@ -0,0 +1,306 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Detail SEGS by Scale Factor node contract.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.nodes.detail_segs_by_scale_factor import DetailSEGSByScaleFactor +from simple_syrup.runtime import sampling_samplers, sampling_schedulers +from simple_syrup.services.detail_segs_by_scale_factor_service import DetailerResult + + +def test_detail_segs_by_scale_factor_node_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Detailer node exposes planned inputs, outputs, and category.""" + + monkeypatch.setattr(sampling_samplers, "available_samplers", lambda: ("euler",)) + monkeypatch.setattr( + sampling_schedulers, "available_schedulers", lambda: ("normal",) + ) + inputs = DetailSEGSByScaleFactor.INPUT_TYPES() + + assert DetailSEGSByScaleFactor.RETURN_TYPES == ("IMAGE",) + assert DetailSEGSByScaleFactor.RETURN_NAMES == ("image",) + assert DetailSEGSByScaleFactor.INPUT_IS_LIST is True + assert DetailSEGSByScaleFactor.CATEGORY == "SimpleSyrup/Detailing" + assert list(inputs["required"]) == [ + "image", + "segs", + "model", + "vae", + "positive", + "negative", + "scale_factor", + "upscale_method", + "clamp_size", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + "feather", + "noise_mask", + "noise_mask_feather", + "tiled_encode", + "tiled_decode", + ] + assert inputs["required"]["positive"][0] == "CONDITIONING,CONDITIONING_BATCH" + assert inputs["required"]["negative"][0] == "CONDITIONING,CONDITIONING_BATCH" + assert inputs["required"]["upscale_method"][0] == [ + "nearest-exact", + "bilinear", + "area", + "bicubic", + "lanczos", + ] + scale_factor_options = inputs["required"]["scale_factor"][1] + assert scale_factor_options["default"] == 1.5 + assert scale_factor_options["min"] == 1.0 + assert scale_factor_options["max"] == 5.0 + assert scale_factor_options["step"] == 0.1 + assert inputs["required"]["upscale_method"][1]["default"] == "lanczos" + assert inputs["required"]["noise_mask_feather"][1]["default"] == 20 + assert "preview_mode" not in inputs["required"] + + +def test_detail_segs_by_scale_factor_node_returns_only_image( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Detailer node emits only the detailed image.""" + + monkeypatch.setattr( + DetailSEGSByScaleFactor, + "service_class", + _FakeDetailerService, + ) + image = torch.zeros((1, 8, 8, 3)) + + (output_image,) = DetailSEGSByScaleFactor().detail( + image, + _segs(), + object(), + object(), + [], + [], + scale_factor=1.5, + upscale_method="bicubic", + clamp_size=0, + seed=1, + steps=2, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + denoise=0.5, + feather=5, + noise_mask=True, + noise_mask_feather=20, + tiled_encode=False, + tiled_decode=False, + ) + + assert torch.equal(cast(torch.Tensor, output_image), image + 1.0) + + +def test_detail_segs_by_scale_factor_node_accepts_image_batch_and_segs_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Detailer pairs each image in a batch with its matching SEGS payload.""" + + monkeypatch.setattr( + DetailSEGSByScaleFactor, + "service_class", + _FakeDetailerService, + ) + image = torch.zeros((2, 8, 8, 3), dtype=torch.float32) + + (output_image,) = DetailSEGSByScaleFactor().detail( + image=[image], + segs=[_segs(), _segs()], + model=[object()], + vae=[object()], + positive=[[]], + negative=[[]], + scale_factor=[1.5], + upscale_method=["bicubic"], + clamp_size=[0], + seed=[1], + steps=[2], + cfg=[7.0], + sampler_name=["euler"], + scheduler=["normal"], + denoise=[0.5], + feather=[5], + noise_mask=[True], + noise_mask_feather=[20], + tiled_encode=[False], + tiled_decode=[False], + ) + + assert torch.equal(cast(torch.Tensor, output_image), image + 1.0) + + +def test_detail_segs_by_scale_factor_node_rejects_batch_image_with_single_segs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A batch image needs one SEGS payload per image.""" + + monkeypatch.setattr( + DetailSEGSByScaleFactor, + "service_class", + _FakeDetailerService, + ) + + with pytest.raises(ValueError, match="received 2 images and 1 SEGS payload"): + DetailSEGSByScaleFactor().detail( + torch.zeros((2, 8, 8, 3), dtype=torch.float32), + _segs(), + object(), + object(), + [], + [], + scale_factor=1.5, + upscale_method="lanczos", + clamp_size=0, + seed=1, + steps=2, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + denoise=0.5, + feather=5, + noise_mask=True, + noise_mask_feather=20, + tiled_encode=False, + tiled_decode=False, + ) + + +def test_detail_segs_by_scale_factor_node_rejects_mismatched_segs_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Image and SEGS list lengths must match exactly.""" + + monkeypatch.setattr( + DetailSEGSByScaleFactor, + "service_class", + _FakeDetailerService, + ) + + with pytest.raises(ValueError, match="received 2 images and 1 SEGS payload"): + DetailSEGSByScaleFactor().detail( + image=[torch.zeros((2, 8, 8, 3), dtype=torch.float32)], + segs=[_segs()], + model=[object()], + vae=[object()], + positive=[[]], + negative=[[]], + scale_factor=[1.5], + upscale_method=["lanczos"], + clamp_size=[0], + seed=[1], + steps=[2], + cfg=[7.0], + sampler_name=["euler"], + scheduler=["normal"], + denoise=[0.5], + feather=[5], + noise_mask=[True], + noise_mask_feather=[20], + tiled_encode=[False], + tiled_decode=[False], + ) + + +def test_detail_segs_by_scale_factor_node_rejects_single_image_with_segs_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Multiple SEGS payloads cannot target one image.""" + + monkeypatch.setattr( + DetailSEGSByScaleFactor, + "service_class", + _FakeDetailerService, + ) + + with pytest.raises(ValueError, match="received 1 images and 2 SEGS payloads"): + DetailSEGSByScaleFactor().detail( + image=[torch.zeros((1, 8, 8, 3), dtype=torch.float32)], + segs=[_segs(), _segs()], + model=[object()], + vae=[object()], + positive=[[]], + negative=[[]], + scale_factor=[1.5], + upscale_method=["lanczos"], + clamp_size=[0], + seed=[1], + steps=[2], + cfg=[7.0], + sampler_name=["euler"], + scheduler=["normal"], + denoise=[0.5], + feather=[5], + noise_mask=[True], + noise_mask_feather=[20], + tiled_encode=[False], + tiled_decode=[False], + ) + + +class _FakeDetailerService: + """Fake detailer service for node tests.""" + + def detail( + self, + image: object, + segs: object, + model: Any, + vae: Any, + positive: Any, + negative: Any, + scale_factor: float, + upscale_method: str, + clamp_size: int, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + denoise: float, + feather: int, + noise_mask: bool, + noise_mask_feather: int, + tiled_encode: bool, + tiled_decode: bool, + ) -> DetailerResult: + """Return deterministic detailer output.""" + + del segs, model, vae, positive, negative, scale_factor + del upscale_method, clamp_size, seed, steps, cfg + del sampler_name, scheduler, denoise, feather, noise_mask + del noise_mask_feather, tiled_encode, tiled_decode + return DetailerResult(image=cast(torch.Tensor, image) + 1.0) + + +def _segs() -> NativeSegs: + """Return native SEGS for node tests.""" + + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((2, 2)), + confidence=1.0, + crop_region=CropRegion(0, 0, 2, 2), + bbox=BoundingBox(0, 0, 2, 2), + label="face", + ) + return (8, 8), (segment,) diff --git a/tests/test_detail_segs_by_scale_factor_service.py b/tests/test_detail_segs_by_scale_factor_service.py new file mode 100644 index 0000000..b6004ee --- /dev/null +++ b/tests/test_detail_segs_by_scale_factor_service.py @@ -0,0 +1,594 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Detail SEGS by Scale Factor service.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.runtime.detail_previews import DetailPreviewContext +from simple_syrup.runtime.detail_sampling import Latent +from simple_syrup.services.detail_segs_by_scale_factor_service import ( + DetailResizeBoundary, + DetailSamplingBoundary, + DetailSEGSByScaleFactorService, +) + + +def test_empty_segs_return_original_image() -> None: + """No SEGS regions leave the input image unchanged.""" + + sampler = _FakeSampler() + service = _service(sampler) + image = _image() + + result = service.detail( + image, ((8, 8), ()), object(), object(), [], [], **_settings() + ) + + assert torch.equal(result.image, image) + assert sampler.sample_calls == [] + + +def test_one_segment_calls_sampling_once() -> None: + """One SEGS segment produces one sample call.""" + + sampler = _FakeSampler() + service = _service(sampler) + + result = service.detail( + _image(), _segs(_segment()), object(), object(), [], [], **_settings() + ) + + assert result.image.shape == (1, 8, 8, 3) + assert [call.seed for call in sampler.sample_calls] == [123] + assert sampler.sample_calls[0].preview_context is not None + assert sampler.sample_calls[0].preview_context.work_region == CropRegion(2, 2, 6, 6) + assert torch.equal( + sampler.sample_calls[0].preview_context.work_mask, + torch.ones((4, 4), dtype=torch.float32), + ) + + +def test_two_segments_increment_seed_deterministically() -> None: + """Each processed segment increments the seed by SEGS order.""" + + sampler = _FakeSampler() + first = _segment(CropRegion(0, 0, 4, 4), BoundingBox(1, 1, 3, 3)) + second = _segment(CropRegion(4, 4, 8, 8), BoundingBox(5, 5, 7, 7)) + service = _service(sampler) + + service.detail( + _image(), _segs(first, second), object(), object(), [], [], **_settings() + ) + + assert [call.seed for call in sampler.sample_calls] == [123, 124] + + +def test_later_segment_preview_context_uses_current_working_image() -> None: + """Preview context reflects already-composited earlier segment edits.""" + + sampler = _FakeSampler() + first = _segment(CropRegion(0, 0, 4, 4), BoundingBox(1, 1, 3, 3)) + second = _segment(CropRegion(4, 4, 8, 8), BoundingBox(5, 5, 7, 7)) + service = _service(sampler) + + service.detail( + _image(), _segs(first, second), object(), object(), [], [], **_settings() + ) + + second_context = sampler.sample_calls[1].preview_context + assert second_context is not None + assert torch.all(second_context.image[:, 0:4, 0:4, :] == 1.0) + assert second_context.work_region == CropRegion(4, 4, 8, 8) + + +def test_normal_conditioning_broadcasts_to_every_segment() -> None: + """Normal conditionings are reused for each processed segment.""" + + sampler = _FakeSampler() + first = _segment(CropRegion(0, 0, 4, 4), BoundingBox(1, 1, 3, 3)) + second = _segment(CropRegion(4, 4, 8, 8), BoundingBox(5, 5, 7, 7)) + positive = object() + negative = object() + service = _service(sampler) + + service.detail( + _image(), + _segs(first, second), + object(), + object(), + positive, + negative, + **_settings(), + ) + + assert [call.positive for call in sampler.sample_calls] == [positive, positive] + assert [call.negative for call in sampler.sample_calls] == [negative, negative] + + +def test_positive_conditioning_batch_selects_by_segment_index() -> None: + """A positive batch varies by SEG while normal negative broadcasts.""" + + sampler = _FakeSampler() + first = _segment(CropRegion(0, 0, 4, 4), BoundingBox(1, 1, 3, 3)) + second = _segment(CropRegion(4, 4, 8, 8), BoundingBox(5, 5, 7, 7)) + negative = object() + service = _service(sampler) + + service.detail( + _image(), + _segs(first, second), + object(), + object(), + ConditioningBatch(("positive 1", "positive 2")), + negative, + **_settings(), + ) + + assert [call.positive for call in sampler.sample_calls] == [ + "positive 1", + "positive 2", + ] + assert [call.negative for call in sampler.sample_calls] == [negative, negative] + + +def test_negative_conditioning_batch_selects_by_segment_index() -> None: + """A negative batch varies by SEG while normal positive broadcasts.""" + + sampler = _FakeSampler() + first = _segment(CropRegion(0, 0, 4, 4), BoundingBox(1, 1, 3, 3)) + second = _segment(CropRegion(4, 4, 8, 8), BoundingBox(5, 5, 7, 7)) + positive = object() + service = _service(sampler) + + service.detail( + _image(), + _segs(first, second), + object(), + object(), + positive, + ConditioningBatch(("negative 1", "negative 2")), + **_settings(), + ) + + assert [call.positive for call in sampler.sample_calls] == [positive, positive] + assert [call.negative for call in sampler.sample_calls] == [ + "negative 1", + "negative 2", + ] + + +def test_conditioning_batches_reuse_last_entry_for_extra_segments() -> None: + """Batch selection falls back to the last prompt when SEGS outnumber entries.""" + + sampler = _FakeSampler() + first = _segment(CropRegion(0, 0, 4, 4), BoundingBox(1, 1, 3, 3)) + second = _segment(CropRegion(4, 4, 8, 8), BoundingBox(5, 5, 7, 7)) + service = _service(sampler) + + service.detail( + _image(), + _segs(first, second), + object(), + object(), + ConditioningBatch(("positive 1",)), + ConditioningBatch(("negative 1", "negative 2")), + **_settings(), + ) + + assert [call.positive for call in sampler.sample_calls] == [ + "positive 1", + "positive 1", + ] + assert [call.negative for call in sampler.sample_calls] == [ + "negative 1", + "negative 2", + ] + + +def test_scale_factor_controls_target_crop_size() -> None: + """The crop region, not the bbox, controls scaled sampling size.""" + + sampler = _FakeSampler() + segment = _segment(CropRegion(0, 0, 8, 4), BoundingBox(2, 1, 4, 3)) + service = _service(sampler) + + service.detail( + _image(), + _segs(segment), + object(), + object(), + [], + [], + **(_settings() | {"scale_factor": 2.0, "clamp_size": 0}), + ) + + assert sampler.encoded_shapes == [(1, 8, 16, 3)] + + +def test_selected_upscale_method_resizes_crop_up() -> None: + """The user-selected method controls crop upscale only.""" + + sampler = _FakeSampler() + image_resizer = _FakeImageResizer() + service = _service(sampler, image_resizer) + + service.detail( + _image(), + _segs(_segment()), + object(), + object(), + [], + [], + **(_settings() | {"upscale_method": "bicubic"}), + ) + + assert image_resizer.upscale_calls == [((1, 4, 4, 3), 8, 8, "bicubic")] + + +def test_decoded_detail_downscales_with_lanczos() -> None: + """Decoded detail always uses fixed Lanczos downscaling.""" + + sampler = _FakeSampler() + image_resizer = _FakeImageResizer() + service = _service(sampler, image_resizer) + + service.detail( + _image(), _segs(_segment()), object(), object(), [], [], **_settings() + ) + + assert image_resizer.downscale_calls == [((1, 8, 8, 3), 4, 4)] + + +def test_positive_clamp_limits_target_crop_size() -> None: + """A positive clamp limits the scaled crop long side.""" + + sampler = _FakeSampler() + segment = _segment(CropRegion(0, 0, 8, 4), BoundingBox(2, 1, 4, 3)) + service = _service(sampler) + + service.detail( + _image(), + _segs(segment), + object(), + object(), + [], + [], + **(_settings() | {"scale_factor": 4.0, "clamp_size": 12}), + ) + + assert sampler.encoded_shapes == [(1, 6, 12, 3)] + + +def test_unscaled_crops_are_always_sampled() -> None: + """Every SEGS region is detailed even when scaling is unnecessary.""" + + sampler = _FakeSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + object(), + object(), + [], + [], + **(_settings() | {"scale_factor": 1.0}), + ) + + assert len(sampler.sample_calls) == 1 + assert sampler.encoded_shapes == [(1, 4, 4, 3)] + + +def test_downscale_factors_sample_at_original_crop_size() -> None: + """Scale factors below one still inpaint at the crop's original size.""" + + sampler = _FakeSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + object(), + object(), + [], + [], + **(_settings() | {"scale_factor": 0.5}), + ) + + assert len(sampler.sample_calls) == 1 + assert sampler.encoded_shapes == [(1, 4, 4, 3)] + + +def test_noise_mask_true_attaches_latent_mask() -> None: + """Noise-mask mode passes a latent mask to sampling.""" + + sampler = _FakeSampler() + service = _service(sampler) + + service.detail( + _image(), _segs(_segment()), object(), object(), [], [], **_settings() + ) + + assert sampler.sample_calls[0].noise_mask_shape == (1, 4, 4) + + +def test_noise_mask_false_samples_without_latent_mask() -> None: + """Disabling noise_mask omits latent denoise masks.""" + + sampler = _FakeSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + object(), + object(), + [], + [], + **(_settings() | {"noise_mask": False}), + ) + + assert sampler.sample_calls[0].noise_mask_shape is None + + +def test_non_tensor_cropped_mask_is_used_as_crop() -> None: + """Impact-compatible non-tensor cropped masks are already crop-local.""" + + sampler = _FakeSampler() + segment = _segment( + CropRegion(4, 4, 8, 8), + BoundingBox(5, 5, 7, 7), + cropped_mask=[ + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + ], + ) + service = _service(sampler) + + service.detail(_image(), _segs(segment), object(), object(), [], [], **_settings()) + + assert sampler.sample_calls[0].noise_mask_shape == (1, 4, 4) + + +def test_noise_mask_feather_applies_model_patch() -> None: + """Feathered noise masks request differential diffusion patching.""" + + sampler = _FakeSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + object(), + object(), + [], + [], + **(_settings() | {"noise_mask_feather": 2}), + ) + + assert sampler.patch_count == 1 + + +def test_batch_images_fail_clearly() -> None: + """The first detailer version rejects image batches.""" + + service = _service(_FakeSampler()) + + with pytest.raises(ValueError, match="supports one image at a time"): + service.detail( + torch.zeros((2, 8, 8, 3)), + _segs(_segment()), + object(), + object(), + [], + [], + **_settings(), + ) + + +class _SampleCall: + """Record one fake sampling call.""" + + def __init__( + self, + seed: int, + noise_mask_shape: tuple[int, ...] | None, + positive: Any, + negative: Any, + preview_context: DetailPreviewContext | None, + ) -> None: + """Store call fields needed by assertions.""" + + self.seed = seed + self.noise_mask_shape = noise_mask_shape + self.positive = positive + self.negative = negative + self.preview_context = preview_context + + +class _FakeSampler: + """Fake VAE/sampling adapter for detailer service tests.""" + + def __init__(self) -> None: + """Create empty call records.""" + + self.encoded_shapes: list[tuple[int, ...]] = [] + self.sample_calls: list[_SampleCall] = [] + self.patch_count = 0 + + def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent: + """Record encoded pixel shape and return a small latent.""" + + del vae, tiled + self.encoded_shapes.append(tuple(int(dim) for dim in pixels.shape)) + return { + "samples": torch.zeros( + ( + 1, + 4, + max(1, int(pixels.shape[1]) // 2), + max(1, int(pixels.shape[2]) // 2), + ) + ), + "pixel_shape": tuple(int(dim) for dim in pixels.shape), + } + + def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor: + """Return a bright crop matching the encoded pixel shape.""" + + del vae, tiled + shape = cast(tuple[int, int, int, int], latent["pixel_shape"]) + return torch.ones(shape, dtype=torch.float32) + + def sample( + self, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Record sample options and return the latent unchanged.""" + + del model, steps, cfg, sampler_name, scheduler, denoise + noise_mask = latent_image.get("noise_mask") + noise_mask_shape = ( + tuple(int(dim) for dim in noise_mask.shape) + if isinstance(noise_mask, torch.Tensor) + else None + ) + self.sample_calls.append( + _SampleCall(seed, noise_mask_shape, positive, negative, preview_context) + ) + return latent_image + + def apply_differential_diffusion(self, model: Any) -> Any: + """Record patching and return the model unchanged.""" + + self.patch_count += 1 + return model + + +class _FakeImageResizer: + """Fake detail image resizer for service tests.""" + + def __init__(self) -> None: + """Create empty call records.""" + + self.upscale_calls: list[tuple[tuple[int, ...], int, int, str]] = [] + self.downscale_calls: list[tuple[tuple[int, ...], int, int]] = [] + + def resize_up( + self, + image: torch.Tensor, + height: int, + width: int, + method: str, + ) -> torch.Tensor: + """Record upscale options and return a shaped crop.""" + + self.upscale_calls.append( + (tuple(int(dim) for dim in image.shape), height, width, method) + ) + return torch.zeros((int(image.shape[0]), height, width, int(image.shape[3]))) + + def resize_down_lanczos( + self, + image: torch.Tensor, + height: int, + width: int, + ) -> torch.Tensor: + """Record downscale options and return a shaped detail crop.""" + + self.downscale_calls.append( + (tuple(int(dim) for dim in image.shape), height, width) + ) + return torch.ones((int(image.shape[0]), height, width, int(image.shape[3]))) + + +def _service( + sampler: _FakeSampler, + image_resizer: _FakeImageResizer | None = None, +) -> DetailSEGSByScaleFactorService: + """Create a service with fake collaborators.""" + + return DetailSEGSByScaleFactorService( + sampler=cast(DetailSamplingBoundary, sampler), + image_resizer=cast( + DetailResizeBoundary, + image_resizer or _FakeImageResizer(), + ), + ) + + +def _segs(*segments: Segment) -> NativeSegs: + """Return native SEGS for an 8x8 image.""" + + return (8, 8), tuple(segments) + + +def _segment( + crop_region: CropRegion | None = None, + bbox: BoundingBox | None = None, + cropped_mask: object | None = None, +) -> Segment: + """Return one test segment with a cropped mask.""" + + resolved_crop_region = crop_region or CropRegion(2, 2, 6, 6) + resolved_bbox = bbox or BoundingBox(3, 3, 5, 5) + return Segment( + cropped_image=None, + cropped_mask=( + cropped_mask + if cropped_mask is not None + else torch.ones((resolved_crop_region.height, resolved_crop_region.width)) + ), + confidence=1.0, + crop_region=resolved_crop_region, + bbox=resolved_bbox, + label="face", + ) + + +def _image() -> torch.Tensor: + """Return a dark 8x8 image tensor.""" + + return torch.zeros((1, 8, 8, 3), dtype=torch.float32) + + +def _settings() -> dict[str, Any]: + """Return valid detailer settings for service tests.""" + + return { + "scale_factor": 2.0, + "upscale_method": "lanczos", + "clamp_size": 0, + "seed": 123, + "steps": 4, + "cfg": 7.0, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 0.5, + "feather": 0, + "noise_mask": True, + "noise_mask_feather": 0, + "tiled_encode": False, + "tiled_decode": False, + } diff --git a/tests/test_detail_segs_by_scale_factor_tiled_diffusion_node.py b/tests/test_detail_segs_by_scale_factor_tiled_diffusion_node.py new file mode 100644 index 0000000..b7a0647 --- /dev/null +++ b/tests/test_detail_segs_by_scale_factor_tiled_diffusion_node.py @@ -0,0 +1,212 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the tiled diffusion scale-factor detailer node contract.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.nodes.detail_segs_by_scale_factor_tiled_diffusion import ( + DetailSEGSByScaleFactorTiledDiffusion, +) +from simple_syrup.runtime import sampling_samplers, sampling_schedulers +from simple_syrup.services.detail_segs_by_scale_factor_tiled_diffusion_service import ( + TiledDetailerResult, +) + + +def test_tiled_detailer_node_contract(monkeypatch: pytest.MonkeyPatch) -> None: + """Tiled detailer node exposes its planned ComfyUI contract.""" + + monkeypatch.setattr(sampling_samplers, "available_samplers", lambda: ("euler",)) + monkeypatch.setattr( + sampling_schedulers, + "available_schedulers", + lambda: ("normal",), + ) + inputs = DetailSEGSByScaleFactorTiledDiffusion.INPUT_TYPES() + + assert DetailSEGSByScaleFactorTiledDiffusion.RETURN_TYPES == ("IMAGE",) + assert DetailSEGSByScaleFactorTiledDiffusion.RETURN_NAMES == ("image",) + assert DetailSEGSByScaleFactorTiledDiffusion.INPUT_IS_LIST is True + assert DetailSEGSByScaleFactorTiledDiffusion.CATEGORY == "SimpleSyrup/Detailing" + assert list(inputs["required"]) == [ + "image", + "segs", + "model", + "vae", + "positive", + "negative", + "scale_factor", + "upscale_method", + "clamp_size", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + "feather", + "noise_mask", + "noise_mask_feather", + "tiled_encode", + "tiled_decode", + "diffusion_mode", + "latent_tile_width", + "latent_tile_height", + "latent_tile_overlap", + "latent_tile_batch_size", + ] + assert inputs["required"]["diffusion_mode"][0] == [ + "multidiffusion", + "mixture_of_diffusers", + ] + scale_factor_options = inputs["required"]["scale_factor"][1] + assert scale_factor_options["default"] == 1.5 + assert scale_factor_options["min"] == 1.0 + assert scale_factor_options["max"] == 5.0 + assert scale_factor_options["step"] == 0.1 + assert inputs["required"]["diffusion_mode"][1]["default"] == "multidiffusion" + assert inputs["required"]["latent_tile_width"][1]["default"] == 128 + assert inputs["required"]["latent_tile_batch_size"][1]["default"] == 4 + + +def test_tiled_detailer_node_delegates_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tiled detailer forwards normalized values to its service.""" + + fake_service = _FakeTiledDetailerService() + monkeypatch.setattr( + DetailSEGSByScaleFactorTiledDiffusion, + "service_class", + staticmethod(lambda: fake_service), + ) + image = torch.zeros((1, 8, 8, 3), dtype=torch.float32) + + (output_image,) = DetailSEGSByScaleFactorTiledDiffusion().detail( + image, + _segs(), + model="model", + vae="vae", + positive=["positive"], + negative=["negative"], + scale_factor=1.5, + upscale_method="bicubic", + clamp_size=0, + seed=1, + steps=2, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + denoise=0.5, + feather=5, + noise_mask=True, + noise_mask_feather=20, + tiled_encode=False, + tiled_decode=True, + diffusion_mode="mixture_of_diffusers", + latent_tile_width=64, + latent_tile_height=80, + latent_tile_overlap=12, + latent_tile_batch_size=3, + ) + + assert torch.equal(cast(torch.Tensor, output_image), image + 1.0) + call = fake_service.calls[0] + assert call["upscale_method"] == "bicubic" + assert call["diffusion_mode"] == "mixture_of_diffusers" + assert call["latent_tile_width"] == 64 + assert call["latent_tile_height"] == 80 + assert call["latent_tile_overlap"] == 12 + assert call["latent_tile_batch_size"] == 3 + + +class _FakeTiledDetailerService: + """Fake tiled detailer service for node tests.""" + + def __init__(self) -> None: + """Create a call-recording fake service.""" + + self.calls: list[dict[str, object]] = [] + + def detail( + self, + image: object, + segs: object, + model: Any, + vae: Any, + positive: Any, + negative: Any, + scale_factor: float, + upscale_method: str, + clamp_size: int, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + denoise: float, + feather: int, + noise_mask: bool, + noise_mask_feather: int, + tiled_encode: bool, + tiled_decode: bool, + diffusion_mode: str, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + ) -> TiledDetailerResult: + """Return deterministic output and record tiled detailer inputs.""" + + self.calls.append( + { + "image": image, + "segs": segs, + "model": model, + "vae": vae, + "positive": positive, + "negative": negative, + "scale_factor": scale_factor, + "upscale_method": upscale_method, + "clamp_size": clamp_size, + "seed": seed, + "steps": steps, + "cfg": cfg, + "sampler_name": sampler_name, + "scheduler": scheduler, + "denoise": denoise, + "feather": feather, + "noise_mask": noise_mask, + "noise_mask_feather": noise_mask_feather, + "tiled_encode": tiled_encode, + "tiled_decode": tiled_decode, + "diffusion_mode": diffusion_mode, + "latent_tile_width": latent_tile_width, + "latent_tile_height": latent_tile_height, + "latent_tile_overlap": latent_tile_overlap, + "latent_tile_batch_size": latent_tile_batch_size, + } + ) + return TiledDetailerResult(image=cast(torch.Tensor, image) + 1.0) + + +def _segs() -> NativeSegs: + """Return native SEGS for node tests.""" + + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((2, 2)), + confidence=1.0, + crop_region=CropRegion(0, 0, 2, 2), + bbox=BoundingBox(0, 0, 2, 2), + label="face", + ) + return (8, 8), (segment,) diff --git a/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py b/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py new file mode 100644 index 0000000..78ab37b --- /dev/null +++ b/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py @@ -0,0 +1,443 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the tiled diffusion scale-factor SEGS detailer service.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.runtime.detail_previews import DetailPreviewContext +from simple_syrup.runtime.detail_sampling import Latent +from simple_syrup.services.detail_segs_by_scale_factor_tiled_diffusion_service import ( + DetailSEGSByScaleFactorTiledDiffusionService, + TiledDetailResizeBoundary, + TiledDetailSampler, + TiledDetailSamplingBoundary, +) + + +def test_empty_segs_return_original_without_sampling() -> None: + """Empty SEGS avoid encode and tiled sampling.""" + + sampler = _FakeTiledSampler() + service = _service(sampler) + image = _image() + + result = service.detail( + image, + ((8, 8), ()), + object(), + object(), + [], + [], + **_settings(), + ) + + assert torch.equal(result.image, image) + assert sampler.sample_calls == [] + assert sampler.encoded_shapes == [] + + +def test_multidiffusion_mode_routes_tile_controls() -> None: + """The tiled detailer forwards MultiDiffusion and tile settings.""" + + sampler = _FakeTiledSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "positive", + "negative", + **( + _settings() + | { + "diffusion_mode": "multidiffusion", + "latent_tile_width": 64, + "latent_tile_height": 80, + "latent_tile_overlap": 12, + "latent_tile_batch_size": 3, + } + ), + ) + + call = sampler.sample_calls[0] + assert call["diffusion_mode"] == "multidiffusion" + assert call["latent_tile_width"] == 64 + assert call["latent_tile_height"] == 80 + assert call["latent_tile_overlap"] == 12 + assert call["latent_tile_batch_size"] == 3 + + +def test_mixture_mode_routes_to_tiled_sampler() -> None: + """Mixture of Diffusers is accepted as a tiled crop mode.""" + + sampler = _FakeTiledSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "positive", + "negative", + **(_settings() | {"diffusion_mode": "mixture_of_diffusers"}), + ) + + assert sampler.sample_calls[0]["diffusion_mode"] == "mixture_of_diffusers" + + +def test_invalid_diffusion_mode_fails_before_encode() -> None: + """Unsupported tiled modes fail before runtime side effects.""" + + sampler = _FakeTiledSampler() + service = _service(sampler) + + with pytest.raises(ValueError, match="diffusion_mode"): + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "positive", + "negative", + **(_settings() | {"diffusion_mode": "full_latent"}), + ) + + assert sampler.encoded_shapes == [] + + +def test_upscale_and_downscale_routing_is_preserved() -> None: + """Selected upscale and fixed Lanczos downscale are both used.""" + + sampler = _FakeTiledSampler() + image_resizer = _FakeImageResizer() + service = _service(sampler, image_resizer) + + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + "positive", + "negative", + **(_settings() | {"upscale_method": "area"}), + ) + + assert image_resizer.upscale_calls == [((1, 4, 4, 3), 8, 8, "area")] + assert image_resizer.downscale_calls == [((1, 8, 8, 3), 4, 4)] + + +def test_tiled_detailer_forwards_detail_preview_context() -> None: + """Tiled crop sampling uses the shared detailer preview context.""" + + sampler = _FakeTiledSampler() + service = _service(sampler) + + service.detail(_image(), _segs(_segment()), "model", "vae", [], [], **_settings()) + + preview_context = sampler.sample_calls[0]["preview_context"] + assert isinstance(preview_context, DetailPreviewContext) + assert preview_context.work_region == CropRegion(2, 2, 6, 6) + assert torch.equal(preview_context.work_mask, torch.ones((4, 4))) + + +def test_noise_mask_true_attaches_latent_mask() -> None: + """Noise-mask mode attaches a latent mask before tiled sampling.""" + + sampler = _FakeTiledSampler() + service = _service(sampler) + + service.detail(_image(), _segs(_segment()), "model", "vae", [], [], **_settings()) + + noise_mask = sampler.sample_calls[0]["latent_image"]["noise_mask"] + assert noise_mask.shape == (1, 4, 4) + + +def test_tiled_detail_sampler_delegates_to_shared_sampling_service() -> None: + """The detailer adapter uses the shared tiled diffusion dispatch service.""" + + tiled_sampling_service = _FakeTiledSamplingService() + latent = {"samples": torch.zeros((1, 4, 4, 4))} + preview_context = DetailPreviewContext( + image=_image(), + work_region=CropRegion(2, 2, 6, 6), + work_mask=torch.ones((4, 4)), + ) + + result = TiledDetailSampler( + tiled_sampling_service=tiled_sampling_service + ).sample_tiled( + diffusion_mode="mixture_of_diffusers", + model="model", + seed=123, + steps=4, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + positive="positive", + negative="negative", + latent_image=latent, + denoise=0.5, + latent_tile_width=128, + latent_tile_height=80, + latent_tile_overlap=12, + latent_tile_batch_size=3, + preview_context=preview_context, + ) + + assert result is latent + call = tiled_sampling_service.calls[0] + assert call["diffusion_mode"] == "mixture_of_diffusers" + assert call["latent_image"] is latent + assert call["preview_context"] is preview_context + + +class _FakeTiledSamplingService: + """Fake shared tiled diffusion sampling service.""" + + def __init__(self) -> None: + """Create empty call records.""" + + self.calls: list[dict[str, Any]] = [] + + def sample( + self, + *, + diffusion_mode: str, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Record tiled sampling arguments and return the latent unchanged.""" + + self.calls.append( + { + "diffusion_mode": diffusion_mode, + "model": model, + "seed": seed, + "steps": steps, + "cfg": cfg, + "sampler_name": sampler_name, + "scheduler": scheduler, + "positive": positive, + "negative": negative, + "latent_image": latent_image, + "denoise": denoise, + "latent_tile_width": latent_tile_width, + "latent_tile_height": latent_tile_height, + "latent_tile_overlap": latent_tile_overlap, + "latent_tile_batch_size": latent_tile_batch_size, + "preview_context": preview_context, + } + ) + return latent_image + + +class _FakeTiledSampler: + """Fake tiled sampling boundary for service tests.""" + + def __init__(self) -> None: + """Create empty call records.""" + + self.encoded_shapes: list[tuple[int, ...]] = [] + self.sample_calls: list[dict[str, Any]] = [] + self.patch_count = 0 + + def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent: + """Record encoded pixel shape and return a latent.""" + + del vae, tiled + self.encoded_shapes.append(tuple(int(dim) for dim in pixels.shape)) + return { + "samples": torch.zeros( + ( + 1, + 4, + max(1, int(pixels.shape[1]) // 2), + max(1, int(pixels.shape[2]) // 2), + ) + ), + "pixel_shape": tuple(int(dim) for dim in pixels.shape), + } + + def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor: + """Return a bright crop matching the encoded pixel shape.""" + + del vae, tiled + shape = cast(tuple[int, int, int, int], latent["pixel_shape"]) + return torch.ones(shape, dtype=torch.float32) + + def sample_tiled( + self, + *, + diffusion_mode: str, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None = None, + ) -> Latent: + """Record tiled sample options and return the latent unchanged.""" + + self.sample_calls.append( + { + "diffusion_mode": diffusion_mode, + "model": model, + "seed": seed, + "steps": steps, + "cfg": cfg, + "sampler_name": sampler_name, + "scheduler": scheduler, + "positive": positive, + "negative": negative, + "latent_image": latent_image, + "denoise": denoise, + "latent_tile_width": latent_tile_width, + "latent_tile_height": latent_tile_height, + "latent_tile_overlap": latent_tile_overlap, + "latent_tile_batch_size": latent_tile_batch_size, + "preview_context": preview_context, + } + ) + return latent_image + + def apply_differential_diffusion(self, model: Any) -> Any: + """Record patching and return the model unchanged.""" + + self.patch_count += 1 + return model + + +class _FakeImageResizer: + """Fake detail image resizer for tiled service tests.""" + + def __init__(self) -> None: + """Create empty call records.""" + + self.upscale_calls: list[tuple[tuple[int, ...], int, int, str]] = [] + self.downscale_calls: list[tuple[tuple[int, ...], int, int]] = [] + + def resize_up( + self, + image: torch.Tensor, + height: int, + width: int, + method: str, + ) -> torch.Tensor: + """Record upscale options and return a shaped crop.""" + + self.upscale_calls.append( + (tuple(int(dim) for dim in image.shape), height, width, method) + ) + return torch.zeros((int(image.shape[0]), height, width, int(image.shape[3]))) + + def resize_down_lanczos( + self, + image: torch.Tensor, + height: int, + width: int, + ) -> torch.Tensor: + """Record downscale options and return a shaped detail crop.""" + + self.downscale_calls.append( + (tuple(int(dim) for dim in image.shape), height, width) + ) + return torch.ones((int(image.shape[0]), height, width, int(image.shape[3]))) + + +def _service( + sampler: _FakeTiledSampler, + image_resizer: _FakeImageResizer | None = None, +) -> DetailSEGSByScaleFactorTiledDiffusionService: + """Create a service with fake collaborators.""" + + return DetailSEGSByScaleFactorTiledDiffusionService( + sampler=cast(TiledDetailSamplingBoundary, sampler), + image_resizer=cast( + TiledDetailResizeBoundary, + image_resizer or _FakeImageResizer(), + ), + ) + + +def _segs(*segments: Segment) -> NativeSegs: + """Return native SEGS for an 8x8 image.""" + + return (8, 8), tuple(segments) + + +def _segment() -> Segment: + """Return one test segment with a cropped mask.""" + + return Segment( + cropped_image=None, + cropped_mask=torch.ones((4, 4)), + confidence=1.0, + crop_region=CropRegion(2, 2, 6, 6), + bbox=BoundingBox(3, 3, 5, 5), + label="face", + ) + + +def _image() -> torch.Tensor: + """Return a dark 8x8 image tensor.""" + + return torch.zeros((1, 8, 8, 3), dtype=torch.float32) + + +def _settings() -> dict[str, Any]: + """Return valid tiled detailer settings for service tests.""" + + return { + "scale_factor": 2.0, + "upscale_method": "lanczos", + "clamp_size": 0, + "seed": 123, + "steps": 4, + "cfg": 7.0, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 0.5, + "feather": 0, + "noise_mask": True, + "noise_mask_feather": 0, + "tiled_encode": False, + "tiled_decode": False, + "diffusion_mode": "multidiffusion", + "latent_tile_width": 128, + "latent_tile_height": 128, + "latent_tile_overlap": 16, + "latent_tile_batch_size": 4, + } diff --git a/tests/test_detect_segs_with_ultralytics_node.py b/tests/test_detect_segs_with_ultralytics_node.py new file mode 100644 index 0000000..bfc02c2 --- /dev/null +++ b/tests/test_detect_segs_with_ultralytics_node.py @@ -0,0 +1,466 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Detect SEGS w/ Ultralytics node contract.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import pytest +import torch + +from simple_syrup.domain.segs import ( + SORT_ORDER_OPTIONS, + BoundingBox, + CropRegion, + NativeSegs, + Segment, +) +from simple_syrup.nodes.detect_segs_with_ultralytics import DetectSEGSWithUltralytics +from simple_syrup.runtime.ultralytics_loader import UltralyticsDetectorModel +from simple_syrup.services.segs_output_service import CombinedSegsResult + + +def test_detect_segs_with_ultralytics_node_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The node exposes the two-output SEGS and mask contract.""" + + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeSegsService, + ) + + inputs = DetectSEGSWithUltralytics.INPUT_TYPES() + + assert DetectSEGSWithUltralytics.RETURN_TYPES == ("SEGS", "MASK") + assert DetectSEGSWithUltralytics.RETURN_NAMES == ("segs", "mask") + assert DetectSEGSWithUltralytics.OUTPUT_IS_LIST == (True, False) + assert DetectSEGSWithUltralytics.OUTPUT_TOOLTIPS == ( + "Detected regions as separate or combined SEGS based on combine_segs.", + "Combined detected area as a standard ComfyUI mask.", + ) + assert DetectSEGSWithUltralytics.CATEGORY == "SimpleSyrup/Detection" + assert DetectSEGSWithUltralytics.DESCRIPTION == ( + "Detects regions with an Ultralytics model and returns individual SEGS, " + "combined SEGS when requested, and a combined mask." + ) + assert list(inputs["required"]) == [ + "image", + "detector_model", + "confidence_threshold", + "size_threshold", + "bbox_dilation", + "sub_dilation", + "post_dilation", + "crop_factor", + "sort_order", + "combine_segs", + ] + assert inputs["required"]["bbox_dilation"][1]["min"] == -512 + assert inputs["required"]["size_threshold"][1]["default"] == 10 + assert inputs["required"]["post_dilation"][1]["default"] == 0 + assert inputs["required"]["crop_factor"][1]["min"] == 0.0 + assert inputs["required"]["sort_order"][0] == SORT_ORDER_OPTIONS + assert inputs["required"]["sort_order"][1]["default"] == "largest to smallest" + assert inputs["required"]["combine_segs"][0] == "BOOLEAN" + assert inputs["required"]["combine_segs"][1]["default"] is False + assert "bbox_threshold" not in inputs["required"] + assert "sub_threshold" not in inputs["required"] + assert "drop_size" not in inputs["required"] + assert "optional" not in inputs + assert "hidden" not in inputs + for input_declaration in inputs["required"].values(): + assert "tooltip" in input_declaration[1] + + +def test_detector_returns_individual_segs_when_combine_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The SEGS output keeps individual regions when combine_segs is false.""" + + builder = _FakeCombinedBuilder() + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeSegsService, + ) + monkeypatch.setattr(DetectSEGSWithUltralytics, "combined_builder", builder) + + segs, mask = _detect(combine_segs=False) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + assert len(segs_list) == 1 + header, segments = segs_list[0] + assert header == (8, 8) + assert isinstance(segments, list) + assert [segment.label for segment in segments] == ["face"] + assert builder.call_count == 1 + assert torch.equal(cast(torch.Tensor, mask), torch.ones((1, 8, 8))) + + +def test_detector_returns_unioned_segs_when_combine_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The SEGS output uses the combined region when combine_segs is true.""" + + builder = _FakeCombinedBuilder() + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeSegsService, + ) + monkeypatch.setattr(DetectSEGSWithUltralytics, "combined_builder", builder) + + segs, mask = _detect(combine_segs=True) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + header, segments = segs_list[0] + assert header == (8, 8) + assert [segment.label for segment in segments] == ["combined"] + assert builder.call_count == 1 + assert torch.equal(cast(torch.Tensor, mask), torch.ones((1, 8, 8))) + + +def test_detect_segs_with_ultralytics_node_uses_story_input_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The detector controls execute in the same order the node declares them.""" + + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeStoryOrderSegsService, + ) + + DetectSEGSWithUltralytics().detect( + torch.zeros((1, 8, 8, 3)), + _model(), + 0.7, + 12, + -2, + 3, + -1, + 2.5, + "largest to smallest", + False, + ) + + +def test_detector_node_sorts_segs_before_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Detected SEGS are sorted before normal and combined outputs are built.""" + + builder = _FakeCombinedBuilder() + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeUnsortedSegsService, + ) + monkeypatch.setattr(DetectSEGSWithUltralytics, "combined_builder", builder) + + segs, _mask = DetectSEGSWithUltralytics().detect( + torch.zeros((1, 8, 8, 3)), + _model(), + 0.7, + 1, + 0, + 0, + 0, + 1.0, + "largest to smallest", + False, + ) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + _header, segments = segs_list[0] + assert [segment.label for segment in segments] == ["large", "small"] + assert builder.seen_labels == [["large", "small"]] + + +def test_detector_node_processes_image_batches_with_individual_segs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A batch image produces one individual SEGS output per image.""" + + builder = _FakeBatchCombinedBuilder() + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeBatchSegsService, + ) + monkeypatch.setattr(DetectSEGSWithUltralytics, "combined_builder", builder) + image = torch.zeros((2, 8, 8, 3), dtype=torch.float32) + image[1] = 1.0 + + segs, mask = DetectSEGSWithUltralytics().detect( + image, + _model(), + 0.5, + 1, + 0, + 0, + 0, + 1.0, + "largest to smallest", + False, + ) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + assert len(segs_list) == 2 + assert [segments[0].label for _header, segments in segs_list] == [ + "image-0", + "image-1", + ] + assert [header for header, _segments in segs_list] == [(8, 8), (8, 8)] + assert builder.call_count == 2 + assert cast(torch.Tensor, mask).shape == (2, 8, 8) + + +def test_detector_node_processes_image_batches_with_unioned_segs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A batch image produces one combined SEGS output per image.""" + + builder = _FakeBatchCombinedBuilder() + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeBatchSegsService, + ) + monkeypatch.setattr(DetectSEGSWithUltralytics, "combined_builder", builder) + + segs, mask = DetectSEGSWithUltralytics().detect( + torch.zeros((2, 8, 8, 3), dtype=torch.float32), + _model(), + 0.5, + 1, + 0, + 0, + 0, + 1.0, + "largest to smallest", + True, + ) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + assert [segments[0].label for _header, segments in segs_list] == [ + "combined-image-0", + "combined-image-1", + ] + assert builder.call_count == 2 + assert cast(torch.Tensor, mask).shape == (2, 8, 8) + + +class _FakeSegsService: + """Fake SEGS service for node tests.""" + + def detect_simple( + self, + image: object, + detector_model: UltralyticsDetectorModel, + bbox_threshold: float, + bbox_dilation: int, + crop_factor: float, + drop_size: int, + sub_threshold: float, + sub_dilation: int, + post_dilation: int = 0, + ) -> NativeSegs: + """Return deterministic native SEGS.""" + + del image, detector_model, crop_factor, drop_size + assert bbox_threshold == 0.5 + assert bbox_dilation == 0 + assert sub_threshold == 0.5 + assert sub_dilation == 2 + assert post_dilation == -1 + return (8, 8), (_segment("face", CropRegion(0, 0, 2, 2)),) + + +class _FakeUnsortedSegsService: + """Fake SEGS service that returns segments out of sorted order.""" + + def detect_simple( + self, + image: object, + detector_model: UltralyticsDetectorModel, + bbox_threshold: float, + bbox_dilation: int, + crop_factor: float, + drop_size: int, + sub_threshold: float, + sub_dilation: int, + post_dilation: int = 0, + ) -> NativeSegs: + """Return small then large segments while checking threshold forwarding.""" + + del image, detector_model, bbox_dilation, crop_factor, drop_size + del sub_dilation, post_dilation + assert bbox_threshold == 0.7 + assert sub_threshold == 0.7 + return ( + (8, 8), + ( + _segment("small", CropRegion(0, 0, 2, 2), confidence=0.9), + _segment("large", CropRegion(0, 0, 4, 4), confidence=0.5), + ), + ) + + +class _FakeBatchSegsService: + """Fake SEGS service that returns one labeled segment per image call.""" + + def __init__(self) -> None: + """Create an empty image-call counter.""" + + self._call_count = 0 + + def detect_simple( + self, + image: object, + detector_model: UltralyticsDetectorModel, + bbox_threshold: float, + bbox_dilation: int, + crop_factor: float, + drop_size: int, + sub_threshold: float, + sub_dilation: int, + post_dilation: int = 0, + ) -> NativeSegs: + """Return a label identifying the one-image slice processed.""" + + del detector_model, bbox_threshold, bbox_dilation, crop_factor + del drop_size, sub_threshold, sub_dilation, post_dilation + assert cast(torch.Tensor, image).shape == (1, 8, 8, 3) + label = f"image-{self._call_count}" + self._call_count += 1 + return (8, 8), (_segment(label, CropRegion(0, 0, 2, 2)),) + + +class _FakeStoryOrderSegsService: + """Fake SEGS service that verifies public detector input mapping.""" + + def detect_simple( + self, + image: object, + detector_model: UltralyticsDetectorModel, + bbox_threshold: float, + bbox_dilation: int, + crop_factor: float, + drop_size: int, + sub_threshold: float, + sub_dilation: int, + post_dilation: int = 0, + ) -> NativeSegs: + """Check every public input is forwarded to the intended service field.""" + + del image, detector_model + assert bbox_threshold == 0.7 + assert sub_threshold == 0.7 + assert drop_size == 12 + assert bbox_dilation == -2 + assert sub_dilation == 3 + assert post_dilation == -1 + assert crop_factor == 2.5 + return (8, 8), () + + +class _FakeCombinedBuilder: + """Callable fake for combined-output tests.""" + + def __init__(self) -> None: + """Create empty call tracking.""" + + self.call_count = 0 + self.seen_labels: list[list[str]] = [] + + def __call__(self, image: object, segs: NativeSegs) -> CombinedSegsResult: + """Return deterministic combined outputs and record the call.""" + + del image + self.call_count += 1 + header, segments = segs + self.seen_labels.append([segment.label for segment in segments]) + return CombinedSegsResult( + segs=(header, (_segment("combined", CropRegion(0, 0, 4, 4)),)), + mask=torch.ones((1, header[0], header[1]), dtype=torch.float32), + ) + + +class _FakeBatchCombinedBuilder: + """Callable fake that creates combined labels from source labels.""" + + def __init__(self) -> None: + """Create empty call tracking.""" + + self.call_count = 0 + + def __call__(self, image: object, segs: NativeSegs) -> CombinedSegsResult: + """Return a combined segment tied to the source image label.""" + + del image + self.call_count += 1 + header, segments = segs + label = f"combined-{segments[0].label}" if segments else "combined" + return CombinedSegsResult( + segs=(header, (_segment(label, CropRegion(0, 0, 4, 4)),)), + mask=torch.ones((1, header[0], header[1]), dtype=torch.float32), + ) + + +def _detect(combine_segs: bool) -> tuple[object, object]: + """Run the detector node with standard single-image settings.""" + + return DetectSEGSWithUltralytics().detect( + torch.zeros((1, 8, 8, 3)), + _model(), + 0.5, + 1, + 0, + 2, + -1, + 1.0, + "largest to smallest", + combine_segs, + ) + + +def _segment( + label: str, + crop_region: CropRegion, + confidence: float = 1.0, +) -> Segment: + """Create a deterministic test segment.""" + + return Segment( + cropped_image=None, + cropped_mask=torch.ones((crop_region.height, crop_region.width)), + confidence=confidence, + crop_region=crop_region, + bbox=BoundingBox( + crop_region.left, + crop_region.top, + crop_region.right, + crop_region.bottom, + ), + label=label, + ) + + +def _model() -> UltralyticsDetectorModel: + """Return a native detector model test double.""" + + return UltralyticsDetectorModel( + model_name="model.pt", + model_path=Path("model.pt"), + model=object(), + task="segment", + names={0: "face"}, + supports_segmentation=True, + ) diff --git a/tests/test_detector_compat.py b/tests/test_detector_compat.py new file mode 100644 index 0000000..8851002 --- /dev/null +++ b/tests/test_detector_compat.py @@ -0,0 +1,121 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for detector compatibility facades.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment +from simple_syrup.runtime.detector_compat import BBoxDetectorFacade, SegmDetectorFacade +from simple_syrup.runtime.ultralytics_loader import UltralyticsDetectorModel + + +def test_bbox_facade_accepts_detector_signature( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """BBox facade accepts the expected detector arguments.""" + + _patch_service(monkeypatch, prefer_segmentation_expected=False) + facade = BBoxDetectorFacade(_model(supports_segmentation=False)) + + header, segments = cast( + tuple[object, list[Segment]], + facade.detect(torch.zeros((1, 8, 8, 3)), 0.5, 1, 2.0, 3, None), + ) + + assert header == (8, 8) + assert segments[0].label == "face" + + +def test_segmentation_facade_has_bbox_detector( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Segmentation facade exposes its paired bbox detector.""" + + _patch_service(monkeypatch, prefer_segmentation_expected=True) + bbox = BBoxDetectorFacade(_model(supports_segmentation=True)) + facade = SegmDetectorFacade(bbox.detector_model, bbox) + + facade.detect(torch.zeros((1, 8, 8, 3)), 0.5, 1, 2.0) + + assert facade.bbox_detector is bbox + + +def test_segmentation_facade_falls_back_for_bbox_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A bbox-only native model remains safe on the segmentation facade.""" + + _patch_service(monkeypatch, prefer_segmentation_expected=False) + bbox = BBoxDetectorFacade(_model(supports_segmentation=False)) + facade = SegmDetectorFacade(bbox.detector_model, bbox) + + header, segments = cast( + tuple[object, list[Segment]], + facade.detect(torch.zeros((1, 8, 8, 3)), 0.5, 0, 1.0), + ) + + assert header == (8, 8) + assert segments[0].label == "face" + + +class _FakeService: + """Fake native detection service for facade tests.""" + + expected_prefer_segmentation: bool = False + + def detect( + self, + image: object, + detector_model: object, + threshold: float, + dilation: int, + crop_factor: float, + drop_size: int, + prefer_segmentation: bool = True, + ) -> object: + """Return deterministic SEGS and verify delegation flags.""" + + del image, detector_model, threshold, dilation, crop_factor, drop_size + assert prefer_segmentation is self.expected_prefer_segmentation + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((4, 4)), + confidence=1.0, + crop_region=CropRegion(0, 0, 4, 4), + bbox=BoundingBox(1, 1, 3, 3), + label="face", + ) + return (8, 8), (segment,) + + +def _patch_service( + monkeypatch: pytest.MonkeyPatch, + prefer_segmentation_expected: bool, +) -> None: + """Patch the lazily imported detection service used by facades.""" + + import simple_syrup.services.segs_detection_service as service_module + + _FakeService.expected_prefer_segmentation = prefer_segmentation_expected + monkeypatch.setattr(service_module, "SegsDetectionService", _FakeService) + + +def _model(supports_segmentation: bool) -> UltralyticsDetectorModel: + """Return a native detector model test double.""" + + return UltralyticsDetectorModel( + model_name="model.pt", + model_path=Path("model.pt"), + model=object(), + task="segment" if supports_segmentation else "detect", + names={0: "face"}, + supports_segmentation=supports_segmentation, + ) diff --git a/tests/test_encode_prompt_batch_node.py b/tests/test_encode_prompt_batch_node.py new file mode 100644 index 0000000..1974040 --- /dev/null +++ b/tests/test_encode_prompt_batch_node.py @@ -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 + +"""Tests for standard prompt batch encoding node.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.nodes.encode_prompt_batch import EncodePromptBatch + + +def test_encode_prompt_batch_contract() -> None: + """Prompt batch node exposes the planned standard encoder shape.""" + + inputs = EncodePromptBatch.INPUT_TYPES() + + assert EncodePromptBatch.RETURN_TYPES == ( + "CONDITIONING_BATCH", + "CONDITIONING_BATCH", + ) + assert EncodePromptBatch.RETURN_NAMES == ("positive", "negative") + assert EncodePromptBatch.CATEGORY == "SimpleSyrup/Conditioning" + assert list(inputs["required"]) == [ + "clip", + "positive_prompt", + "negative_prompt", + "separator", + ] + assert inputs["required"]["clip"][0] == "CLIP" + assert inputs["required"]["positive_prompt"][1]["default"] == "" + assert inputs["required"]["negative_prompt"][1]["default"] == "" + assert inputs["required"]["separator"][1]["default"] == "[SEP]" + + +def test_encode_prompt_batch_splits_and_encodes_each_chunk( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Standard encoder batches positive and negative chunks independently.""" + + monkeypatch.setattr(EncodePromptBatch, "encoder_class", _FakeEncoder) + + positive, negative = EncodePromptBatch().encode( + clip="clip", + positive_prompt="face [SEP] hair", + negative_prompt="blur", + separator="[SEP]", + ) + + assert isinstance(positive, ConditioningBatch) + assert isinstance(negative, ConditioningBatch) + assert positive.entries == ("clip:face", "clip:hair") + assert negative.entries == ("clip:blur",) + + +def test_encode_prompt_batch_encodes_blank_and_empty_chunks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Blank prompts and trailing separator chunks still become entries.""" + + monkeypatch.setattr(EncodePromptBatch, "encoder_class", _FakeEncoder) + + positive, negative = EncodePromptBatch().encode( + clip="clip", + positive_prompt="", + negative_prompt="bad [SEP]", + separator="[SEP]", + ) + + assert isinstance(positive, ConditioningBatch) + assert isinstance(negative, ConditioningBatch) + assert positive.entries == ("clip:",) + assert negative.entries == ("clip:bad", "clip:") + + +class _FakeEncoder: + """Fake prompt encoder for node tests.""" + + def encode_batch( + self, + clip: Any, + chunks: tuple[str, ...], + ) -> ConditioningBatch: + """Return visible conditioning entries for assertions.""" + + return ConditioningBatch(tuple(f"{clip}:{chunk}" for chunk in chunks)) diff --git a/tests/test_encode_prompt_batch_with_prompt_control_node.py b/tests/test_encode_prompt_batch_with_prompt_control_node.py new file mode 100644 index 0000000..3314558 --- /dev/null +++ b/tests/test_encode_prompt_batch_with_prompt_control_node.py @@ -0,0 +1,50 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Prompt Control prompt batch node schema.""" + +from __future__ import annotations + +from simple_syrup.nodes_v3.encode_prompt_batch_with_prompt_control import ( + EncodePromptBatchWithPromptControl, +) + + +def test_prompt_control_prompt_batch_node_schema() -> None: + """The v3 node exposes the planned lazy Prompt Control contract.""" + + schema = EncodePromptBatchWithPromptControl.define_schema() + + assert schema.node_id == "SimpleSyrup.EncodePromptBatchWithPromptControl" + assert schema.display_name == "Encode Prompt Batch w/ Prompt Control" + assert schema.enable_expand is True + assert schema.category == "SimpleSyrup/Conditioning" + assert [output.io_type for output in schema.outputs] == [ + "CONDITIONING_BATCH", + "CONDITIONING_BATCH", + ] + assert [output.id for output in schema.outputs] == ["positive", "negative"] + + +def test_prompt_control_prompt_batch_input_types() -> None: + """The finalized v3 schema exposes Comfy-compatible input and output types.""" + + inputs = EncodePromptBatchWithPromptControl.INPUT_TYPES() + + assert EncodePromptBatchWithPromptControl.RETURN_TYPES == [ + "CONDITIONING_BATCH", + "CONDITIONING_BATCH", + ] + assert EncodePromptBatchWithPromptControl.RETURN_NAMES == [ + "positive", + "negative", + ] + assert list(inputs["required"]) == [ + "clip", + "positive_prompt", + "negative_prompt", + "separator", + ] + assert inputs["required"]["clip"][0] == "CLIP" + assert inputs["required"]["separator"][1]["default"] == "[SEP]" diff --git a/tests/test_graph_provenance.py b/tests/test_graph_provenance.py new file mode 100644 index 0000000..5906ede --- /dev/null +++ b/tests/test_graph_provenance.py @@ -0,0 +1,221 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Comfy graph provenance tracing.""" + +from __future__ import annotations + +from typing import Any + +from simple_syrup.domain.graph_provenance import BrokenProvenance, VaeDecodeProvenance +from simple_syrup.runtime.comfy_graph_provenance import trace_vae_decode_provenance + + +class TransparentNode: + """Fake node that declares exact value pass-through provenance.""" + + GRAPH_PASSTHROUGH_OUTPUTS = {0: "value"} + + +class NonTransparentNode: + """Fake node that intentionally has no pass-through contract.""" + + +class MalformedTransparentNode: + """Fake node with invalid pass-through metadata.""" + + GRAPH_PASSTHROUGH_OUTPUTS = {0: 1} + + +def test_direct_vae_decode_resolves_samples_and_vae_links() -> None: + """Direct VAEDecode image output resolves to the source latent and VAE.""" + + prompt = { + "decode": { + "class_type": "VAEDecode", + "inputs": {"samples": ["latent", 0], "vae": ["loader", 2]}, + } + } + + result = trace_vae_decode_provenance(prompt, ["decode", 0], {}) + + assert isinstance(result, VaeDecodeProvenance) + assert result.decode_node_id == "decode" + assert result.image_output == ("decode", 0) + assert result.samples_link == ("latent", 0) + assert result.vae_link == ("loader", 2) + + +def test_transparent_node_resolves_to_upstream_vae_decode() -> None: + """A declared pass-through node is traversed to its source link.""" + + prompt = { + "marker": { + "class_type": "TransparentNode", + "inputs": {"value": ["decode", 0]}, + }, + "decode": { + "class_type": "VAEDecode", + "inputs": {"samples": ["latent", 0], "vae": ["loader", 2]}, + }, + } + + result = trace_vae_decode_provenance( + prompt, + ["marker", 0], + {"TransparentNode": TransparentNode}, + ) + + assert isinstance(result, VaeDecodeProvenance) + assert result.samples_link == ("latent", 0) + + +def test_multiple_transparent_nodes_resolve_to_upstream_vae_decode() -> None: + """Transparent marker chains preserve decode provenance.""" + + prompt = { + "outer": { + "class_type": "TransparentNode", + "inputs": {"value": ["inner", 0]}, + }, + "inner": { + "class_type": "TransparentNode", + "inputs": {"value": ["decode", 0]}, + }, + "decode": { + "class_type": "VAEDecode", + "inputs": {"samples": ["latent", 0], "vae": ["loader", 2]}, + }, + } + + result = trace_vae_decode_provenance( + prompt, + ["outer", 0], + {"TransparentNode": TransparentNode}, + ) + + assert isinstance(result, VaeDecodeProvenance) + assert result.decode_node_id == "decode" + + +def test_non_transparent_node_breaks_provenance() -> None: + """Nodes without an exact pass-through contract stop tracing.""" + + result = trace_vae_decode_provenance( + { + "edited": { + "class_type": "NonTransparentNode", + "inputs": {"image": ["decode", 0]}, + } + }, + ["edited", 0], + {"NonTransparentNode": NonTransparentNode}, + ) + + assert isinstance(result, BrokenProvenance) + assert result.reason == "source node does not declare exact pass-through provenance" + assert result.node_id == "edited" + assert result.class_type == "NonTransparentNode" + + +def test_vae_decode_non_image_output_breaks_provenance() -> None: + """Only VAEDecode output slot 0 is trusted as decoded image provenance.""" + + result = trace_vae_decode_provenance( + { + "decode": { + "class_type": "VAEDecode", + "inputs": {"samples": ["latent", 0], "vae": ["loader", 2]}, + } + }, + ["decode", 1], + {}, + ) + + assert isinstance(result, BrokenProvenance) + assert result.reason == "VAEDecode output is not the image output" + + +def test_missing_samples_link_breaks_provenance() -> None: + """VAEDecode without a graph-linked samples input cannot supply provenance.""" + + result = trace_vae_decode_provenance( + {"decode": {"class_type": "VAEDecode", "inputs": {"samples": "latent"}}}, + ["decode", 0], + {}, + ) + + assert isinstance(result, BrokenProvenance) + assert result.reason == "VAEDecode samples input is not a graph link" + + +def test_missing_node_breaks_provenance() -> None: + """Missing source nodes produce broken provenance.""" + + result = trace_vae_decode_provenance({}, ["missing", 0], {}) + + assert isinstance(result, BrokenProvenance) + assert result.reason == "source node is missing" + assert result.node_id == "missing" + + +def test_malformed_passthrough_metadata_breaks_provenance() -> None: + """Invalid transparency metadata is treated as unsafe.""" + + prompt: dict[str, dict[str, Any]] = { + "marker": { + "class_type": "MalformedTransparentNode", + "inputs": {"value": ["decode", 0]}, + } + } + + result = trace_vae_decode_provenance( + prompt, + ["marker", 0], + {"MalformedTransparentNode": MalformedTransparentNode}, + ) + + assert isinstance(result, BrokenProvenance) + assert result.reason == "pass-through metadata is malformed" + + +def test_cycle_is_detected() -> None: + """Tracing stops when transparent nodes form a cycle.""" + + prompt = { + "a": {"class_type": "TransparentNode", "inputs": {"value": ["b", 0]}}, + "b": {"class_type": "TransparentNode", "inputs": {"value": ["a", 0]}}, + } + + result = trace_vae_decode_provenance( + prompt, + ["a", 0], + {"TransparentNode": TransparentNode}, + ) + + assert isinstance(result, BrokenProvenance) + assert result.reason == "provenance trace contains a cycle" + + +def test_hop_limit_is_enforced() -> None: + """Tracing stops before walking unbounded transparent chains.""" + + prompt = { + "a": {"class_type": "TransparentNode", "inputs": {"value": ["b", 0]}}, + "b": {"class_type": "TransparentNode", "inputs": {"value": ["decode", 0]}}, + "decode": { + "class_type": "VAEDecode", + "inputs": {"samples": ["latent", 0], "vae": ["loader", 2]}, + }, + } + + result = trace_vae_decode_provenance( + prompt, + ["a", 0], + {"TransparentNode": TransparentNode}, + max_hops=1, + ) + + assert isinstance(result, BrokenProvenance) + assert result.reason == "provenance trace exceeded the hop limit" diff --git a/tests/test_grounded_sam_model_info_node.py b/tests/test_grounded_sam_model_info_node.py new file mode 100644 index 0000000..7a74052 --- /dev/null +++ b/tests/test_grounded_sam_model_info_node.py @@ -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 + +"""Tests for the Grounded SAM Model Info node declaration.""" + +from __future__ import annotations + +from typing import Any + +from simple_syrup.nodes.grounded_sam_model_info import GroundedSAMModelInfo + + +def test_model_info_node_contract_constants() -> None: + """Model info node constants match the public ComfyUI contract.""" + + assert GroundedSAMModelInfo.RETURN_TYPES == ("STRING",) + assert GroundedSAMModelInfo.RETURN_NAMES == ("model_info",) + assert GroundedSAMModelInfo.FUNCTION == "describe" + assert GroundedSAMModelInfo.CATEGORY == "SimpleSyrup/Masking" + + +def test_model_info_node_declares_expected_inputs() -> None: + """Model info node exposes model selectors.""" + + input_types: dict[str, dict[str, tuple[Any, ...]]] = ( + GroundedSAMModelInfo.INPUT_TYPES() + ) + required = input_types["required"] + + assert set(required) == {"sam_model", "grounding_dino_model"} + assert "sam_hq_vit_b (379MB)" in required["sam_model"][0] + assert "GroundingDINO_SwinT_OGC (694MB)" in required["grounding_dino_model"][0] + + +def test_model_info_node_delegates_to_metadata_provider() -> None: + """Node execution delegates metadata creation to its metadata provider.""" + + class FakeMetadata: + """Metadata double for model info.""" + + def describe_selection(self, sam_model: str, grounding_dino_model: str) -> str: + """Return deterministic metadata.""" + + return f"{sam_model}|{grounding_dino_model}" + + node = GroundedSAMModelInfo() + original = GroundedSAMModelInfo._metadata + GroundedSAMModelInfo._metadata = FakeMetadata() # type: ignore[assignment] + try: + result = node.describe("sam", "dino") + finally: + GroundedSAMModelInfo._metadata = original + + assert result == ("sam|dino",) diff --git a/tests/test_grounding_dino_loader.py b/tests/test_grounding_dino_loader.py new file mode 100644 index 0000000..fb1ee0c --- /dev/null +++ b/tests/test_grounding_dino_loader.py @@ -0,0 +1,343 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for GroundingDINO loader runtime service.""" + +from __future__ import annotations + +import importlib +import sys +from dataclasses import dataclass, field +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest +import torch + +from simple_syrup.runtime.grounding_dino_loader import ( + GROUNDING_DINO_RUNTIME_PACKAGE, + TEXT_ENCODER_AUTO, + TEXT_ENCODER_COMFY, + TEXT_ENCODER_LAYERSTYLE, + GroundingDINOLoaderService, + GroundingDINOModelCacheKey, +) +from simple_syrup.runtime.loaded_models import LoadedGroundingDINOModel +from test_helpers import FakeFolderPaths + + +def test_grounding_dino_loader_resolves_explicit_layerstyle_bert( + tmp_path: Path, +) -> None: + """Explicit LayerStyle BERT mode uses models/bert-base-uncased.""" + + _write_bert(tmp_path / "bert-base-uncased") + resolved = GroundingDINOLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path) + ).resolve_text_encoder(TEXT_ENCODER_LAYERSTYLE, auto_download=False) + + assert resolved.path == tmp_path / "bert-base-uncased" + + +def test_grounding_dino_loader_resolves_explicit_text_encoder_bert( + tmp_path: Path, +) -> None: + """Explicit ComfyUI text encoder mode uses text_encoders/bert.""" + + _write_bert(tmp_path / "text_encoders" / "bert") + resolved = GroundingDINOLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path) + ).resolve_text_encoder(TEXT_ENCODER_COMFY, auto_download=False) + + assert resolved.path == tmp_path / "text_encoders" / "bert" + + +def test_grounding_dino_loader_auto_prefers_layerstyle_bert(tmp_path: Path) -> None: + """Auto text encoder mode preserves the preferred local order.""" + + _write_bert(tmp_path / "bert-base-uncased") + _write_bert(tmp_path / "text_encoders" / "bert") + resolved = GroundingDINOLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path) + ).resolve_text_encoder(TEXT_ENCODER_AUTO, auto_download=False) + + assert resolved.path == tmp_path / "bert-base-uncased" + + +def test_grounding_dino_loader_explicit_missing_bert_does_not_download( + tmp_path: Path, +) -> None: + """Explicit text encoder modes fail instead of silently using another path.""" + + with pytest.raises(FileNotFoundError, match="text_encoders/bert"): + GroundingDINOLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path) + ).resolve_text_encoder(TEXT_ENCODER_COMFY, auto_download=True) + + +def test_grounding_dino_loader_uses_process_cache_for_identical_model( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Identical GroundingDINO loads reuse the same loaded container.""" + + state = _install_fake_grounding_dino(monkeypatch) + _write_grounding_dino_artifacts(tmp_path) + _write_bert(tmp_path / "bert-base-uncased") + cache: dict[GroundingDINOModelCacheKey, LoadedGroundingDINOModel] = {} + service = GroundingDINOLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache=cache, + ) + + first = service.load_model( + "GroundingDINO_SwinT_OGC (694MB)", + TEXT_ENCODER_LAYERSTYLE, + auto_download=True, + ) + second = service.load_model( + "GroundingDINO_SwinT_OGC (694MB)", + TEXT_ENCODER_LAYERSTYLE, + auto_download=True, + ) + + assert second is first + assert first.managed_model is not None + assert state.config_paths == [ + str(tmp_path / "grounding-dino" / "GroundingDINO_SwinT_OGC.cfg.py") + ] + assert state.checkpoint_paths == [ + str(tmp_path / "grounding-dino" / "groundingdino_swint_ogc.pth") + ] + assert len(cache) == 1 + + +def test_grounding_dino_loader_invalidates_import_caches( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Vendored runtime imports refresh caches for already-running Comfy processes.""" + + state = _install_fake_grounding_dino(monkeypatch) + invalidations = 0 + + def invalidate_caches() -> None: + nonlocal invalidations + invalidations += 1 + + monkeypatch.setattr(importlib, "invalidate_caches", invalidate_caches) + _write_grounding_dino_artifacts(tmp_path) + _write_bert(tmp_path / "bert-base-uncased") + + GroundingDINOLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache={}, + ).load_model( + "GroundingDINO_SwinT_OGC (694MB)", + TEXT_ENCODER_LAYERSTYLE, + auto_download=True, + ) + + assert state.build_calls == 1 + assert invalidations == 1 + + +def test_grounding_dino_loader_cache_separates_text_encoder_paths( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Different BERT directories produce separate GroundingDINO instances.""" + + state = _install_fake_grounding_dino(monkeypatch) + _write_grounding_dino_artifacts(tmp_path) + _write_bert(tmp_path / "bert-base-uncased") + _write_bert(tmp_path / "text_encoders" / "bert") + cache: dict[GroundingDINOModelCacheKey, LoadedGroundingDINOModel] = {} + service = GroundingDINOLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache=cache, + ) + + first = service.load_model( + "GroundingDINO_SwinT_OGC (694MB)", + TEXT_ENCODER_LAYERSTYLE, + auto_download=True, + ) + second = service.load_model( + "GroundingDINO_SwinT_OGC (694MB)", + TEXT_ENCODER_COMFY, + auto_download=True, + ) + + assert second is not first + assert state.text_encoder_paths == [ + str(tmp_path / "bert-base-uncased"), + str(tmp_path / "text_encoders" / "bert"), + ] + assert len(cache) == 2 + + +def test_grounding_dino_loader_does_not_cache_failed_model_build( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed GroundingDINO build leaves the cache empty for retry.""" + + state = _install_fake_grounding_dino(monkeypatch, fail_once=True) + _write_grounding_dino_artifacts(tmp_path) + _write_bert(tmp_path / "bert-base-uncased") + cache: dict[GroundingDINOModelCacheKey, LoadedGroundingDINOModel] = {} + service = GroundingDINOLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache=cache, + ) + + with pytest.raises(RuntimeError, match="GroundingDINO failed"): + service.load_model( + "GroundingDINO_SwinT_OGC (694MB)", + TEXT_ENCODER_LAYERSTYLE, + auto_download=True, + ) + + loaded = service.load_model( + "GroundingDINO_SwinT_OGC (694MB)", + TEXT_ENCODER_LAYERSTYLE, + auto_download=True, + ) + + assert isinstance(loaded, LoadedGroundingDINOModel) + assert state.build_calls == 2 + assert len(cache) == 1 + + +def _write_bert(path: Path) -> None: + """Write a minimal valid BERT directory.""" + + path.mkdir(parents=True) + (path / "config.json").write_text("{}", encoding="utf-8") + (path / "tokenizer.json").write_text("{}", encoding="utf-8") + (path / "model.safetensors").write_bytes(b"weights") + + +def _write_grounding_dino_artifacts(tmp_path: Path) -> None: + """Write a minimal GroundingDINO config and checkpoint pair.""" + + model_dir = tmp_path / "grounding-dino" + model_dir.mkdir(parents=True, exist_ok=True) + (model_dir / "GroundingDINO_SwinT_OGC.cfg.py").write_text( + "# config\n", + encoding="utf-8", + ) + (model_dir / "groundingdino_swint_ogc.pth").write_bytes(b"checkpoint") + + +@dataclass +class _FakeGroundingDINOState: + """Record fake GroundingDINO runtime construction.""" + + config_paths: list[str] = field(default_factory=list) + checkpoint_paths: list[str] = field(default_factory=list) + text_encoder_paths: list[str] = field(default_factory=list) + build_calls: int = 0 + fail_once: bool = False + + +class _FakeArgs: + """Small GroundingDINO config object fake.""" + + def __init__(self) -> None: + """Create args that trigger text encoder path substitution.""" + + self.text_encoder_type = "bert-base-uncased" + + +class _FakeSLConfig: + """Fake GroundingDINO SLConfig factory.""" + + state: _FakeGroundingDINOState + + @classmethod + def fromfile(cls, path: str) -> _FakeArgs: + """Record config loading and return mutable fake args.""" + + cls.state.config_paths.append(path) + return _FakeArgs() + + +class _FakeGroundingDINOModel: + """Minimal GroundingDINO model fake.""" + + def __init__(self, state: _FakeGroundingDINOState, text_encoder_path: str) -> None: + """Create a model fake that records selected BERT path.""" + + self.state = state + self.text_encoder_path = text_encoder_path + self.model_name = "" + self.load_calls = 0 + self.eval_calls = 0 + + def load_state_dict(self, state_dict: dict[str, object], strict: bool) -> None: + """Record model weight loading.""" + + _ = state_dict, strict + self.load_calls += 1 + + def eval(self) -> None: + """Record eval mode selection.""" + + self.eval_calls += 1 + + +def _install_fake_grounding_dino( + monkeypatch: pytest.MonkeyPatch, + fail_once: bool = False, +) -> _FakeGroundingDINOState: + """Install fake GroundingDINO and torch load boundaries.""" + + state = _FakeGroundingDINOState(fail_once=fail_once) + _FakeSLConfig.state = state + + groundingdino = ModuleType(GROUNDING_DINO_RUNTIME_PACKAGE) + util = ModuleType(f"{GROUNDING_DINO_RUNTIME_PACKAGE}.util") + slconfig = ModuleType(f"{GROUNDING_DINO_RUNTIME_PACKAGE}.util.slconfig") + utils = ModuleType(f"{GROUNDING_DINO_RUNTIME_PACKAGE}.util.utils") + models = ModuleType(f"{GROUNDING_DINO_RUNTIME_PACKAGE}.models") + + slconfig.SLConfig = _FakeSLConfig # type: ignore[attr-defined] + utils.clean_state_dict = lambda state_dict: state_dict # type: ignore[attr-defined] + + def build_model(args: Any) -> _FakeGroundingDINOModel: + """Record model construction and optionally fail once.""" + + state.build_calls += 1 + state.text_encoder_paths.append(str(args.text_encoder_type)) + if state.fail_once: + state.fail_once = False + raise RuntimeError("GroundingDINO failed") + return _FakeGroundingDINOModel(state, str(args.text_encoder_type)) + + def load_checkpoint(path: str, map_location: str) -> dict[str, dict[str, object]]: + """Record checkpoint loading and return a fake checkpoint.""" + + assert map_location == "cpu" + state.checkpoint_paths.append(path) + return {"model": {"weight": object()}} + + models.build_model = build_model # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, GROUNDING_DINO_RUNTIME_PACKAGE, groundingdino) + monkeypatch.setitem(sys.modules, f"{GROUNDING_DINO_RUNTIME_PACKAGE}.util", util) + monkeypatch.setitem( + sys.modules, + f"{GROUNDING_DINO_RUNTIME_PACKAGE}.util.slconfig", + slconfig, + ) + monkeypatch.setitem( + sys.modules, + f"{GROUNDING_DINO_RUNTIME_PACKAGE}.util.utils", + utils, + ) + monkeypatch.setitem(sys.modules, f"{GROUNDING_DINO_RUNTIME_PACKAGE}.models", models) + monkeypatch.setattr(torch, "load", load_checkpoint) + return state diff --git a/tests/test_grounding_dino_model_loader_node.py b/tests/test_grounding_dino_model_loader_node.py new file mode 100644 index 0000000..acf05a6 --- /dev/null +++ b/tests/test_grounding_dino_model_loader_node.py @@ -0,0 +1,146 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the GroundingDINO Model Loader node declaration.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from simple_syrup.nodes.grounding_dino_model_loader import GroundingDINOModelLoader +from simple_syrup.runtime.grounding_dino_loader import ( + TEXT_ENCODER_AUTO, + TEXT_ENCODER_COMFY, + TEXT_ENCODER_LAYERSTYLE, +) +from simple_syrup.runtime.model_choices import NO_LOCAL_GROUNDING_DINO_MODELS + + +def test_grounding_dino_model_loader_contract() -> None: + """GroundingDINO loader exposes the SimpleSyrup model socket.""" + + assert GroundingDINOModelLoader.RETURN_TYPES == ("GROUNDING_DINO_MODEL",) + assert GroundingDINOModelLoader.RETURN_NAMES == ("grounding_dino_model",) + assert GroundingDINOModelLoader.FUNCTION == "load_model" + assert GroundingDINOModelLoader.CATEGORY == "SimpleSyrup/Masking" + + +def test_grounding_dino_model_loader_declares_expected_inputs() -> None: + """GroundingDINO loader makes text encoder selection explicit.""" + + input_types: dict[str, dict[str, tuple[Any, ...]]] = ( + GroundingDINOModelLoader.INPUT_TYPES() + ) + required = input_types["required"] + + assert set(required) == { + "grounding_dino_model", + "text_encoder", + } + assert "GroundingDINO_SwinT_OGC (694MB)" in required["grounding_dino_model"][0] + assert TEXT_ENCODER_LAYERSTYLE in required["text_encoder"][0] + assert TEXT_ENCODER_COMFY in required["text_encoder"][0] + assert TEXT_ENCODER_AUTO in required["text_encoder"][0] + + +def test_grounding_dino_model_loader_uses_settings_aware_choices() -> None: + """GroundingDINO loader dropdown choices come from the choice service.""" + + class FakeChoices: + """Choice service double for INPUT_TYPES.""" + + def grounding_dino_choices(self) -> list[str]: + """Return local-only choices.""" + + return ["GroundingDINO_SwinB (938MB)"] + + original = GroundingDINOModelLoader._choices + GroundingDINOModelLoader._choices = FakeChoices() # type: ignore[assignment] + try: + required = GroundingDINOModelLoader.INPUT_TYPES()["required"] + finally: + GroundingDINOModelLoader._choices = original + + assert required["grounding_dino_model"][0] == ["GroundingDINO_SwinB (938MB)"] + assert ( + required["grounding_dino_model"][1]["default"] == "GroundingDINO_SwinB (938MB)" + ) + + +def test_grounding_dino_model_loader_delegates_to_service() -> None: + """Node execution delegates to the loader service.""" + + expected = object() + + class FakeService: + """Service double for node delegation.""" + + def load_model(self, **kwargs: object) -> object: + """Return a fixed model object.""" + + return expected + + node = GroundingDINOModelLoader() + original = GroundingDINOModelLoader._service + GroundingDINOModelLoader._service = FakeService() # type: ignore[assignment] + try: + result = node.load_model( + grounding_dino_model="GroundingDINO_SwinT_OGC (694MB)", + text_encoder=TEXT_ENCODER_AUTO, + ) + finally: + GroundingDINOModelLoader._service = original + + assert result == (expected,) + + +def test_grounding_dino_model_loader_rejects_sentinel_selection() -> None: + """GroundingDINO loader rejects no-local-model sentinel selections.""" + + node = GroundingDINOModelLoader() + + with pytest.raises( + ValueError, + match="No local GroundingDINO models are available", + ): + node.load_model( + grounding_dino_model=NO_LOCAL_GROUNDING_DINO_MODELS, + text_encoder=TEXT_ENCODER_AUTO, + ) + + +def test_grounding_dino_model_loader_always_allows_downloads_for_selected_models() -> ( + None +): + """Selected downloadable models are resolved with internal download enabled.""" + + class FakeService: + """Service double that records node download policy.""" + + def __init__(self) -> None: + """Create a recording service double.""" + + self.kwargs: dict[str, object] | None = None + + def load_model(self, **kwargs: object) -> object: + """Record call arguments and return a fixed model object.""" + + self.kwargs = kwargs + return object() + + fake_service = FakeService() + original = GroundingDINOModelLoader._service + GroundingDINOModelLoader._service = fake_service # type: ignore[assignment] + try: + GroundingDINOModelLoader().load_model( + grounding_dino_model="GroundingDINO_SwinT_OGC (694MB)", + text_encoder=TEXT_ENCODER_AUTO, + ) + finally: + GroundingDINOModelLoader._service = original + + assert fake_service.kwargs is not None + assert fake_service.kwargs["auto_download"] is True diff --git a/tests/test_ksampler_extras_node.py b/tests/test_ksampler_extras_node.py new file mode 100644 index 0000000..79a7776 --- /dev/null +++ b/tests/test_ksampler_extras_node.py @@ -0,0 +1,291 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the KSampler Extras ComfyUI node.""" + +from __future__ import annotations + +import sys +from importlib import import_module +from typing import Any + +import torch + +from simple_syrup.nodes.ksampler_extras import KSamplerExtras +from simple_syrup.runtime import sampling_samplers, sampling_schedulers + +comfy_sample = import_module("comfy.sample") +comfy_utils = import_module("comfy.utils") +latent_preview = import_module("latent_preview") + + +class FakeModel: + """Provide model attributes used by KSampler Extras execution.""" + + def __init__(self) -> None: + """Create a fake sampling model.""" + + self.load_device = torch.device("cpu") + self.model_options: dict[str, object] = {} + self.model_sampling = object() + + def get_model_object(self, name: str) -> object: + """Return the requested fake model object.""" + + assert name == "model_sampling" + return self.model_sampling + + +class FakeSampler: + """Represent a resolved sampler object in node execution tests.""" + + def sample(self, *args: object, **kwargs: object) -> object: + """Provide the sampler protocol expected by runtime code.""" + + return None + + +def test_input_types_match_simple_ksampler_contract() -> None: + """The node exposes the same inputs as ComfyUI's simple KSampler.""" + + required = KSamplerExtras.INPUT_TYPES()["required"] + + assert tuple(required) == ( + "model", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "positive", + "negative", + "latent_image", + "denoise", + ) + + +def test_node_metadata_matches_contract() -> None: + """The node declares the expected ComfyUI output contract.""" + + assert KSamplerExtras.RETURN_TYPES == ("LATENT",) + assert KSamplerExtras.FUNCTION == "sample" + assert KSamplerExtras.CATEGORY == "SimpleSyrup/Sampling" + + +def test_user_facing_text_describes_behavior_without_ownership_language() -> None: + """User-facing copy describes controls without implementation ownership terms.""" + + required = KSamplerExtras.INPUT_TYPES()["required"] + user_facing_text = [ + KSamplerExtras.DESCRIPTION, + required["sampler_name"][1]["tooltip"], + required["scheduler"][1]["tooltip"], + ] + + assert "algorithm" in required["sampler_name"][1]["tooltip"].lower() + assert "noise" in required["scheduler"][1]["tooltip"].lower() + assert all("owned" not in text.lower() for text in user_facing_text) + assert all("ownership" not in text.lower() for text in user_facing_text) + + +def test_sampler_options_include_lcm() -> None: + """Core ComfyUI samplers and local extras are exposed.""" + + sampler_options = KSamplerExtras.INPUT_TYPES()["required"]["sampler_name"][0] + + assert "lcm" in sampler_options + assert "euler_a_a1111" in sampler_options + + +def test_scheduler_options_include_extras_and_exclude_svd() -> None: + """The node exposes supported extra schedulers and excludes unsupported SVD.""" + + scheduler_options = KSamplerExtras.INPUT_TYPES()["required"]["scheduler"][0] + + assert "AYS SD1" in scheduler_options + assert "AYS SDXL" in scheduler_options + assert "GITS" in scheduler_options + assert "beta57" in scheduler_options + assert "automatic_a1111" in scheduler_options + assert "AYS SVD" not in scheduler_options + + +def test_node_import_does_not_require_efficiency_nodes() -> None: + """The node does not import Efficiency Nodes as a runtime dependency.""" + + assert "efficiency_nodes" not in sys.modules + + +def test_sample_delegates_to_runtime_helpers( + monkeypatch: Any, +) -> None: + """Sampling uses SimpleSyrup runtime helpers and ComfyUI sample_custom.""" + + calls: dict[str, Any] = {} + model = FakeModel() + sampler = FakeSampler() + latent_samples = torch.ones((1, 4, 8, 8), dtype=torch.float32) + fixed_noise = torch.full_like(latent_samples, 2.0) + fixed_sigmas = torch.tensor([1.0, 0.0], dtype=torch.float32) + sampled = torch.full_like(latent_samples, 3.0) + latent_image: dict[str, Any] = { + "samples": latent_samples, + "batch_index": [0], + "noise_mask": torch.ones((1, 1, 8, 8), dtype=torch.float32), + "downscale_ratio_spacial": 2, + "kept": "value", + } + + def fake_resolve_sampler(sampler_name: str) -> FakeSampler: + """Record sampler resolution.""" + + calls["sampler_name"] = sampler_name + return sampler + + def fake_calculate_sigmas( + model: FakeModel, + scheduler_name: str, + sampler_name: str, + steps: int, + denoise: float, + ) -> torch.Tensor: + """Record scheduler calculation.""" + + calls["calculate_sigmas"] = { + "model": model, + "scheduler_name": scheduler_name, + "sampler_name": sampler_name, + "steps": steps, + "denoise": denoise, + } + return fixed_sigmas + + def fake_fix_empty_latent_channels( + received_model: FakeModel, + samples: torch.Tensor, + downscale_ratio_spacial: int | None, + ) -> torch.Tensor: + """Record latent channel normalization.""" + + calls["fix_empty_latent_channels"] = { + "model": received_model, + "samples": samples, + "downscale_ratio_spacial": downscale_ratio_spacial, + } + return samples + + def fake_prepare_noise( + samples: torch.Tensor, + seed: int, + batch_inds: list[int], + ) -> torch.Tensor: + """Record noise preparation.""" + + calls["prepare_noise"] = { + "samples": samples, + "seed": seed, + "batch_inds": batch_inds, + } + return fixed_noise + + def fake_prepare_callback(received_model: FakeModel, steps: int) -> str: + """Record callback preparation.""" + + calls["prepare_callback"] = {"model": received_model, "steps": steps} + return "callback" + + def fake_sample_custom( + received_model: FakeModel, + noise: torch.Tensor, + cfg: float, + received_sampler: FakeSampler, + sigmas: torch.Tensor, + positive: object, + negative: object, + latent_image: torch.Tensor, + noise_mask: torch.Tensor | None, + callback: str, + disable_pbar: bool, + seed: int, + ) -> torch.Tensor: + """Record custom sampling arguments.""" + + calls["sample_custom"] = { + "model": received_model, + "noise": noise, + "cfg": cfg, + "sampler": received_sampler, + "sigmas": sigmas, + "positive": positive, + "negative": negative, + "latent_image": latent_image, + "noise_mask": noise_mask, + "callback": callback, + "disable_pbar": disable_pbar, + "seed": seed, + } + return sampled + + monkeypatch.setattr( + sampling_samplers, + "resolve_sampler", + fake_resolve_sampler, + ) + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + fake_calculate_sigmas, + ) + monkeypatch.setattr( + comfy_sample, + "fix_empty_latent_channels", + fake_fix_empty_latent_channels, + ) + monkeypatch.setattr( + comfy_sample, + "prepare_noise", + fake_prepare_noise, + ) + monkeypatch.setattr( + latent_preview, + "prepare_callback", + fake_prepare_callback, + ) + monkeypatch.setattr( + comfy_sample, + "sample_custom", + fake_sample_custom, + ) + monkeypatch.setattr(comfy_utils, "PROGRESS_BAR_ENABLED", False) + + (output,) = KSamplerExtras().sample( + model=model, + seed=123, + steps=2, + cfg=7.5, + sampler_name="lcm", + scheduler="GITS", + positive="positive", + negative="negative", + latent_image=latent_image, + denoise=0.8, + ) + + assert output is not latent_image + assert output["samples"] is sampled + assert output["kept"] == "value" + assert "downscale_ratio_spacial" not in output + assert calls["sampler_name"] == "lcm" + assert calls["calculate_sigmas"] == { + "model": model, + "scheduler_name": "GITS", + "sampler_name": "lcm", + "steps": 2, + "denoise": 0.8, + } + assert calls["prepare_noise"]["batch_inds"] == [0] + assert calls["sample_custom"]["noise_mask"] is latent_image["noise_mask"] + assert calls["sample_custom"]["sampler"] is sampler + assert calls["sample_custom"]["sigmas"] is fixed_sigmas + assert calls["sample_custom"]["disable_pbar"] is True diff --git a/tests/test_ksampler_tiled_diffusion_node.py b/tests/test_ksampler_tiled_diffusion_node.py new file mode 100644 index 0000000..5d59e2b --- /dev/null +++ b/tests/test_ksampler_tiled_diffusion_node.py @@ -0,0 +1,195 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the unified KSampler tiled diffusion ComfyUI node.""" + +from __future__ import annotations + +from typing import Any + +import pytest +import torch + +from simple_syrup.nodes.ksampler_tiled_diffusion import KSamplerTiledDiffusion +from simple_syrup.runtime import sampling_samplers, sampling_schedulers + + +def test_input_types_match_tiled_diffusion_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The node exposes KSampler-style inputs plus mode and tile controls.""" + + monkeypatch.setattr(sampling_samplers, "available_samplers", lambda: ("euler",)) + monkeypatch.setattr( + sampling_schedulers, + "available_schedulers", + lambda: ("normal",), + ) + required = KSamplerTiledDiffusion.INPUT_TYPES()["required"] + + assert tuple(required) == ( + "model", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "positive", + "negative", + "latent_image", + "denoise", + "diffusion_mode", + "latent_tile_width", + "latent_tile_height", + "latent_tile_overlap", + "latent_tile_batch_size", + ) + assert required["diffusion_mode"][0] == [ + "multidiffusion", + "mixture_of_diffusers", + ] + assert required["diffusion_mode"][1]["default"] == "multidiffusion" + assert required["latent_tile_width"][1]["default"] == 128 + assert required["latent_tile_width"][1]["max"] == 512 + assert required["latent_tile_height"][1]["default"] == 128 + assert required["latent_tile_height"][1]["max"] == 512 + assert required["latent_tile_overlap"][1]["default"] == 16 + assert required["latent_tile_batch_size"][1]["default"] == 4 + + +def test_node_metadata_matches_contract() -> None: + """The node declares the expected ComfyUI output contract.""" + + assert KSamplerTiledDiffusion.RETURN_TYPES == ("LATENT",) + assert KSamplerTiledDiffusion.FUNCTION == "sample" + assert KSamplerTiledDiffusion.CATEGORY == "SimpleSyrup/Sampling" + assert not hasattr(KSamplerTiledDiffusion, "RETURN_NAMES") + + +def test_sample_delegates_to_shared_service( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The node remains thin and returns the service output unchanged.""" + + fake_service = _FakeTiledDiffusionSamplingService() + monkeypatch.setattr( + KSamplerTiledDiffusion, + "service_class", + staticmethod(lambda: fake_service), + ) + latent_image = {"samples": torch.zeros((1, 4, 4, 4))} + + (result,) = KSamplerTiledDiffusion().sample( + model="model", + seed=123, + steps=20, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + positive="positive", + negative="negative", + latent_image=latent_image, + denoise=0.8, + diffusion_mode="mixture_of_diffusers", + latent_tile_width=96, + latent_tile_height=80, + latent_tile_overlap=24, + latent_tile_batch_size=3, + ) + + assert result is fake_service.output + call = fake_service.calls[0] + assert call["diffusion_mode"] == "mixture_of_diffusers" + assert call["model"] == "model" + assert call["seed"] == 123 + assert call["steps"] == 20 + assert call["cfg"] == 7.0 + assert call["sampler_name"] == "euler" + assert call["scheduler"] == "normal" + assert call["positive"] == "positive" + assert call["negative"] == "negative" + assert call["latent_image"] is latent_image + assert call["denoise"] == 0.8 + assert call["latent_tile_width"] == 96 + assert call["latent_tile_height"] == 80 + assert call["latent_tile_overlap"] == 24 + assert call["latent_tile_batch_size"] == 3 + assert call["preview_context"] is None + + +def test_invalid_diffusion_mode_fails_before_runtime_sampling() -> None: + """Unsupported modes are rejected before sampler side effects.""" + + with pytest.raises(ValueError, match="diffusion_mode"): + KSamplerTiledDiffusion().sample( + model=object(), + seed=123, + steps=20, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + positive=[], + negative=[], + latent_image={"samples": torch.zeros((1, 4, 4, 4))}, + denoise=0.8, + diffusion_mode="full_latent", + latent_tile_width=128, + latent_tile_height=80, + latent_tile_overlap=24, + latent_tile_batch_size=3, + ) + + +class _FakeTiledDiffusionSamplingService: + """Fake shared sampling service for node tests.""" + + def __init__(self) -> None: + """Create deterministic output and call records.""" + + self.output: dict[str, Any] = {"samples": torch.ones((1, 4, 4, 4))} + self.calls: list[dict[str, Any]] = [] + + def sample( + self, + *, + diffusion_mode: str, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: dict[str, Any], + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: Any | None = None, + ) -> dict[str, Any]: + """Record sampling arguments and return a fixed latent.""" + + self.calls.append( + { + "diffusion_mode": diffusion_mode, + "model": model, + "seed": seed, + "steps": steps, + "cfg": cfg, + "sampler_name": sampler_name, + "scheduler": scheduler, + "positive": positive, + "negative": negative, + "latent_image": latent_image, + "denoise": denoise, + "latent_tile_width": latent_tile_width, + "latent_tile_height": latent_tile_height, + "latent_tile_overlap": latent_tile_overlap, + "latent_tile_batch_size": latent_tile_batch_size, + "preview_context": preview_context, + } + ) + return self.output diff --git a/tests/test_latent_diagnostics_node.py b/tests/test_latent_diagnostics_node.py new file mode 100644 index 0000000..95c2b7d --- /dev/null +++ b/tests/test_latent_diagnostics_node.py @@ -0,0 +1,45 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Latent Diagnostics ComfyUI node.""" + +from __future__ import annotations + +from typing import Any + +import torch + +from simple_syrup.nodes.latent_diagnostics import LatentDiagnostics + + +def test_node_contract_constants() -> None: + """The node declares the expected ComfyUI diagnostic contract.""" + + assert LatentDiagnostics.RETURN_TYPES == ("LATENT", "STRING") + assert LatentDiagnostics.RETURN_NAMES == ("latent", "report") + assert LatentDiagnostics.FUNCTION == "analyze" + assert LatentDiagnostics.CATEGORY == "SimpleSyrup/Utilities" + assert LatentDiagnostics.OUTPUT_NODE is True + + +def test_node_declares_latent_input() -> None: + """The node accepts one latent input.""" + + input_types: dict[str, dict[str, tuple[Any, ...]]] = LatentDiagnostics.INPUT_TYPES() + + assert tuple(input_types["required"]) == ("latent",) + assert input_types["required"]["latent"][0] == "LATENT" + + +def test_analyze_returns_passthrough_latent_and_ui_report() -> None: + """The node returns the original latent alongside a visible text report.""" + + latent = {"samples": torch.zeros((1, 16, 1, 8, 8))} + + result = LatentDiagnostics().analyze(latent) + + assert result["result"][0] is latent + assert result["result"][1] == result["ui"]["text"][0] + assert "shape: [1, 16, 1, 8, 8]" in result["result"][1] + assert "mixture_of_diffusers_current_compatible: yes" in result["result"][1] diff --git a/tests/test_latent_diagnostics_service.py b/tests/test_latent_diagnostics_service.py new file mode 100644 index 0000000..5afb847 --- /dev/null +++ b/tests/test_latent_diagnostics_service.py @@ -0,0 +1,86 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for latent diagnostics report generation.""" + +from __future__ import annotations + +import torch + +from simple_syrup.services.latent_diagnostics_service import LatentDiagnosticsService + + +def test_describe_reports_bchw_latent_as_compatible() -> None: + """A standard ComfyUI BCHW latent is reported as MoD-compatible.""" + + latent = { + "samples": torch.zeros((2, 4, 8, 16), dtype=torch.float16), + "batch_index": [0, 1], + } + + report = LatentDiagnosticsService().describe(latent) + + assert "latent_keys: [batch_index, samples]" in report + assert "shape: [2, 4, 8, 16]" in report + assert "ndim: 4" in report + assert "dtype: torch.float16" in report + assert "height: 8" in report + assert "width: 16" in report + assert "mixture_of_diffusers_current_compatible: yes" in report + assert "batch_index: builtins.list len=2" in report + + +def test_describe_reports_non_4d_tensor_as_incompatible() -> None: + """A tensor with spatial dimensions but no BCHW layout is called out clearly.""" + + latent = {"samples": torch.zeros((4, 8, 16), dtype=torch.float32)} + + report = LatentDiagnosticsService().describe(latent) + + assert "shape: [4, 8, 16]" in report + assert "ndim: 3" in report + assert "spatial_last_dims:" in report + assert "bchw_interpretation: unavailable" in report + assert "mixture_of_diffusers_current_compatible: no" in report + assert "expects non-nested 4D BCHW or singleton-depth 5D BCDHW samples" in report + + +def test_describe_reports_singleton_depth_5d_latent_as_compatible() -> None: + """An Anima-style singleton-depth BCDHW latent is reported as compatible.""" + + latent = {"samples": torch.zeros((1, 16, 1, 252, 180), dtype=torch.float32)} + + report = LatentDiagnosticsService().describe(latent) + + assert "shape: [1, 16, 1, 252, 180]" in report + assert "ndim: 5" in report + assert "bcdhw_interpretation:" in report + assert "depth: 1" in report + assert "height: 252" in report + assert "width: 180" in report + assert "mixture_of_diffusers_current_compatible: yes" in report + + +def test_describe_reports_non_singleton_depth_5d_latent_as_incompatible() -> None: + """A non-singleton BCDHW latent remains unsupported until validated.""" + + latent = {"samples": torch.zeros((1, 16, 2, 8, 8), dtype=torch.float32)} + + report = LatentDiagnosticsService().describe(latent) + + assert "bcdhw_interpretation:" in report + assert "depth: 2" in report + assert "mixture_of_diffusers_current_compatible: no" in report + assert "expects 5D samples to use a singleton depth axis" in report + + +def test_describe_reports_missing_samples_without_crashing() -> None: + """A malformed latent dictionary receives an actionable report.""" + + report = LatentDiagnosticsService().describe({"noise_mask": torch.ones((1, 8, 8))}) + + assert "samples: missing or not a torch.Tensor" in report + assert "samples_type: builtins.NoneType" in report + assert "compatibility_reason: samples must be a torch.Tensor." in report + assert "noise_mask: torch.Tensor shape=[1, 8, 8]" in report diff --git a/tests/test_layerstyle_sam_models_adapter_node.py b/tests/test_layerstyle_sam_models_adapter_node.py new file mode 100644 index 0000000..39ebaeb --- /dev/null +++ b/tests/test_layerstyle_sam_models_adapter_node.py @@ -0,0 +1,39 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the LayerStyle SAM models adapter node.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.nodes.layerstyle_sam_models_adapter import LayerStyleSAMModelsAdapter + + +def test_layerstyle_adapter_contract() -> None: + """Adapter splits LayerStyle bundles into conventional sockets.""" + + assert LayerStyleSAMModelsAdapter.RETURN_TYPES == ("SAM_MODEL", "DINO_MODEL") + assert LayerStyleSAMModelsAdapter.RETURN_NAMES == ("sam_model", "dino_model") + assert LayerStyleSAMModelsAdapter.INPUT_TYPES()["required"]["sam_models"][0] == ( + "LS_SAM_MODELS" + ) + + +def test_layerstyle_adapter_returns_bundle_members() -> None: + """Adapter returns SAM and DINO objects from the bundle.""" + + sam = object() + dino = object() + + result = LayerStyleSAMModelsAdapter().adapt({"SAM_MODEL": sam, "DINO_MODEL": dino}) + + assert result == (sam, dino) + + +def test_layerstyle_adapter_rejects_invalid_bundle() -> None: + """Invalid LayerStyle bundles fail clearly.""" + + with pytest.raises(ValueError, match="SAM_MODEL and DINO_MODEL"): + LayerStyleSAMModelsAdapter().adapt({"SAM_MODEL": object()}) diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py new file mode 100644 index 0000000..e59d00a --- /dev/null +++ b/tests/test_license_headers.py @@ -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 + +"""Test repository license header maintenance.""" + +from __future__ import annotations + +import importlib.util +from datetime import UTC, datetime +from pathlib import Path +from types import ModuleType +from typing import Any, cast + +REPO_ROOT = Path(__file__).resolve().parents[1] +TOOLS_MODULE = REPO_ROOT / "tools" / "add_license_headers.py" +PROJECT_LINE = "SimpleSyrup - workflow-focused ComfyUI extensions for image generation" +SPDX_LINE = "SPDX-License-Identifier: AGPL-3.0-or-later" + + +def _load_module(path: Path) -> ModuleType: + """Load the license header tool directly from its repository path.""" + + spec = importlib.util.spec_from_file_location("add_license_headers_for_test", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load module spec for {path}.") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +license_headers = cast(Any, _load_module(TOOLS_MODULE)) + + +def test_copyright_years_stays_single_year_during_start_year() -> None: + """Keep the initial release year compact while it is still current.""" + + assert license_headers._copyright_years(datetime(2026, 5, 21, tzinfo=UTC)) == "2026" + + +def test_copyright_years_expands_after_start_year() -> None: + """Render a range when the tool is rerun in a later year.""" + + assert ( + license_headers._copyright_years(datetime(2030, 1, 1, tzinfo=UTC)) + == "2026 - 2030" + ) + + +def test_header_uses_typescript_comment_prefix_for_frontend_source() -> None: + """Use TypeScript comments for ComfyUI frontend extension files.""" + + header = license_headers._header(path=Path("web/src/main.ts")) + + assert header.startswith(f"// {PROJECT_LINE}") + assert SPDX_LINE in header + + +def test_direct_vendored_and_generated_source_are_excluded() -> None: + """Keep generated files and direct upstream vendored runtime outside stamping.""" + + should_update = license_headers._should_update + + assert not should_update( + Path("simple_syrup/third_party/groundingdino_runtime/models/registry.py") + ) + assert not should_update( + Path("simple_syrup/third_party/sam_hq_runtime/predictor.py") + ) + assert not should_update(Path("web/dist/simple-syrup.js")) + assert should_update(Path("simple_syrup/runtime/a1111_sampling.py")) + + +def test_tracked_first_party_source_has_project_license_header() -> None: + """Require tracked SimpleSyrup-owned source to carry the AGPL project header.""" + + for source_path in license_headers._tracked_source_files(): + content = (REPO_ROOT / source_path).read_text(encoding="utf-8") + prefix = license_headers._comment_prefix(source_path) + + assert f"{prefix} {PROJECT_LINE}" in content[:500], source_path + assert f"{prefix} {SPDX_LINE}" in content[:500], source_path diff --git a/tests/test_load_ultralytics_model_node.py b/tests/test_load_ultralytics_model_node.py new file mode 100644 index 0000000..264956c --- /dev/null +++ b/tests/test_load_ultralytics_model_node.py @@ -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 + +"""Tests for the Load Ultralytics Model node contract.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest + +from simple_syrup.nodes.load_ultralytics_model import LoadUltralyticsModel +from simple_syrup.runtime.ultralytics_loader import LoadedUltralyticsDetector + + +def test_load_ultralytics_model_node_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Load Ultralytics Model exposes the planned return types and category.""" + + monkeypatch.setattr(LoadUltralyticsModel, "service_class", _FakeLoaderService) + + inputs = LoadUltralyticsModel.INPUT_TYPES() + + assert LoadUltralyticsModel.RETURN_TYPES == ( + "DETECTOR_MODEL", + "BBOX_DETECTOR", + "SEGM_DETECTOR", + ) + assert LoadUltralyticsModel.CATEGORY == "SimpleSyrup/Detection" + assert inputs["required"]["model_name"][0] == ["model.pt"] + + +def test_load_ultralytics_model_node_returns_loaded_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Load Ultralytics Model forwards native and compatibility outputs.""" + + monkeypatch.setattr(LoadUltralyticsModel, "service_class", _FakeLoaderService) + + assert LoadUltralyticsModel().load("model.pt") == ("native", "bbox", "segm") + + +class _FakeLoaderService: + """Fake loader service for node tests.""" + + def model_choices(self) -> list[str]: + """Return deterministic dropdown choices.""" + + return ["model.pt"] + + def load(self, model_name: str) -> LoadedUltralyticsDetector: + """Return deterministic loaded outputs.""" + + assert model_name == "model.pt" + return LoadedUltralyticsDetector(cast(Any, "native"), "bbox", "segm") diff --git a/tests/test_loaded_models.py b/tests/test_loaded_models.py new file mode 100644 index 0000000..0d003f3 --- /dev/null +++ b/tests/test_loaded_models.py @@ -0,0 +1,114 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for loaded model containers.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from simple_syrup.runtime.loaded_models import ( + LoadedGroundingDINOModel, + LoadedSAMModel, + LoadedViTMatteModel, + LoadedWD14Tagger, + unwrap_grounding_dino_model, + unwrap_sam_model, + unwrap_vitmatte_model, + unwrap_wd14_tagger, +) +from simple_syrup.runtime.wd14_tagger import FloatArray, WD14TagRecord + + +def test_loaded_sam_model_unwraps_underlying_model() -> None: + """SAM containers preserve model metadata and unwrap cleanly.""" + + model = object() + loaded = LoadedSAMModel(model=model, source="local", model_id="sam_vit_b") + + assert loaded.source == "local" + assert unwrap_sam_model(loaded) is model + + +def test_loaded_grounding_dino_model_unwraps_underlying_model(tmp_path: Path) -> None: + """GroundingDINO containers preserve text encoder metadata.""" + + model = object() + loaded = LoadedGroundingDINOModel( + model=model, + text_encoder_path=tmp_path / "bert", + source="local", + model_id="groundingdino_swint_ogc", + ) + + assert loaded.text_encoder_path == tmp_path / "bert" + assert unwrap_grounding_dino_model(loaded) is model + + +def test_loaded_vitmatte_model_preserves_model_and_processor(tmp_path: Path) -> None: + """ViTMatte containers preserve model, processor, and source path metadata.""" + + model = object() + processor = object() + loaded = LoadedViTMatteModel( + model=model, + processor=processor, + source="local", + model_id="vitmatte-small-composition-1k", + model_path=tmp_path / "vitmatte-small-composition-1k", + ) + + assert loaded.processor is processor + assert unwrap_vitmatte_model(loaded) is loaded + + +def test_loaded_wd14_tagger_preserves_runtime_metadata(tmp_path: Path) -> None: + """WD14 containers preserve loaded session, tags, paths, and provider metadata.""" + + session = _FakeWD14Session() + tags = (WD14TagRecord("blue_hair", "0"),) + loaded = LoadedWD14Tagger( + model_id="wd-eva02-large-tagger-v3", + source="local", + onnx_path=tmp_path / "model.onnx", + csv_path=tmp_path / "tags.csv", + providers=("CPUExecutionProvider",), + session=session, + tags=tags, + ) + + assert loaded.session is session + assert loaded.tags == tags + assert unwrap_wd14_tagger(loaded) is loaded + + +def test_unwrap_wd14_tagger_rejects_incompatible_object() -> None: + """WD14 unwrap errors name the loader required for compatible objects.""" + + with pytest.raises(TypeError, match="Load WD14 Tagger"): + unwrap_wd14_tagger(object()) + + +class _FakeWD14Session: + """Minimal WD14 session test double.""" + + def get_inputs(self) -> list[object]: + """Return no fake inputs.""" + + return [] + + def get_outputs(self) -> list[object]: + """Return no fake outputs.""" + + return [] + + def run( + self, output_names: list[str], feeds: dict[str, FloatArray] + ) -> list[object]: + """Return no fake outputs.""" + + _ = output_names, feeds + return [] diff --git a/tests/test_mask_ops.py b/tests/test_mask_ops.py new file mode 100644 index 0000000..4cae699 --- /dev/null +++ b/tests/test_mask_ops.py @@ -0,0 +1,113 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for prompt mask composition helpers.""" + +from __future__ import annotations + +import torch + +from simple_syrup.masking.mask_ops import ( + MaskRefinementSettings, + compose_prompt_masks, + histogram_remap, + refine_prompt_mask, + zero_mask_like_image, +) + + +def test_zero_mask_like_image_matches_bhw_shape() -> None: + """Zero mask helper uses image batch and spatial dimensions.""" + + image = torch.ones((2, 4, 6, 3), dtype=torch.float32) + + mask = zero_mask_like_image(image) + + assert mask.shape == (2, 4, 6) + assert torch.count_nonzero(mask) == 0 + + +def test_compose_prompt_masks_returns_positive_when_negative_missing() -> None: + """Positive masks are clamped when no negative mask is supplied.""" + + positive = torch.tensor([[[1.2, 0.4]]], dtype=torch.float32) + + result = compose_prompt_masks(positive) + + assert torch.equal(result, torch.tensor([[[1.0, 0.4]]], dtype=torch.float32)) + + +def test_compose_prompt_masks_subtracts_negative_and_clamps() -> None: + """Negative masks remove matching positive mask regions.""" + + positive = torch.tensor([[[1.0, 0.4, 0.2]]], dtype=torch.float32) + negative = torch.tensor([[[0.25, 0.5, 0.0]]], dtype=torch.float32) + + result = compose_prompt_masks(positive, negative) + + assert torch.equal(result, torch.tensor([[[0.75, 0.0, 0.2]]], dtype=torch.float32)) + + +def test_histogram_remap_uses_black_and_white_points() -> None: + """Black and white points stretch mask values into the ComfyUI mask range.""" + + mask = torch.tensor([[[0.15, 0.57, 0.99]]], dtype=torch.float32) + + result = histogram_remap(mask, black_point=0.15, white_point=0.99) + + assert torch.allclose(result, torch.tensor([[[0.0, 0.5, 1.0]]])) + + +def test_refine_prompt_mask_preserves_shape_with_size_limit() -> None: + """Detail refinement keeps the original BHW mask shape after bounded work.""" + + image = torch.rand((1, 8, 8, 3), dtype=torch.float32) + mask = torch.zeros((1, 8, 8), dtype=torch.float32) + mask[:, 2:6, 2:6] = 1.0 + + result = refine_prompt_mask( + mask, + image, + MaskRefinementSettings( + detail_method="PyMatting", + detail_erode=2, + detail_dilate=2, + black_point=0.0, + white_point=1.0, + process_detail=True, + execution_device="cpu", + max_size_pixels=16, + ), + ) + + assert result.shape == mask.shape + assert result.dtype == torch.float32 + assert torch.all((0.0 <= result) & (result <= 1.0)) + + +def test_refine_prompt_mask_rejects_invalid_points() -> None: + """Invalid level remap settings fail before processing.""" + + image = torch.rand((1, 2, 2, 3), dtype=torch.float32) + mask = torch.ones((1, 2, 2), dtype=torch.float32) + + try: + refine_prompt_mask( + mask, + image, + MaskRefinementSettings( + detail_method="GuidedFilter", + detail_erode=1, + detail_dilate=1, + black_point=0.8, + white_point=0.2, + process_detail=False, + execution_device="cpu", + max_size_pixels=4, + ), + ) + except ValueError as error: + assert "black_point and white_point" in str(error) + else: + raise AssertionError("Expected invalid black/white points to fail.") diff --git a/tests/test_mixture_of_diffusers_sampling.py b/tests/test_mixture_of_diffusers_sampling.py new file mode 100644 index 0000000..0047775 --- /dev/null +++ b/tests/test_mixture_of_diffusers_sampling.py @@ -0,0 +1,638 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Mixture of Diffusers ComfyUI sampling runtime.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.domain.segs import CropRegion +from simple_syrup.domain.tiled_diffusion import gaussian_tile_weights +from simple_syrup.runtime import mixture_of_diffusers_sampling as mod_sampling +from simple_syrup.runtime import sampling_samplers, sampling_schedulers +from simple_syrup.runtime.detail_previews import DetailPreviewContext + +comfy_sample = mod_sampling._comfy_sample() +comfy_utils = mod_sampling._comfy_utils() +latent_preview = mod_sampling._latent_preview() + + +def _preview_context() -> DetailPreviewContext: + """Return a minimal detail preview context for runtime callback tests.""" + + return DetailPreviewContext( + image=torch.ones((1, 8, 8, 3), dtype=torch.float32), + work_region=CropRegion(2, 2, 6, 6), + work_mask=torch.ones((4, 4), dtype=torch.float32), + ) + + +def test_sampling_callback_uses_generic_preview_without_detail_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """KSampler-style Mixture of Diffusers keeps generic latent previews.""" + + monkeypatch.setattr( + latent_preview, + "prepare_callback", + lambda _model, _steps: "generic callback", + ) + + assert mod_sampling._sampling_callback(FakeModel(), 4, None) == "generic callback" + + +def test_sampling_callback_uses_detail_preview_with_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Detailer Mixture of Diffusers uses the shared detail preview callback.""" + + context = _preview_context() + calls: dict[str, object] = {} + + def fake_prepare_detail_preview_callback( + model: FakeModel, + steps: int, + preview_context: DetailPreviewContext, + ) -> str: + """Record detail preview callback preparation.""" + + calls["model"] = model + calls["steps"] = steps + calls["preview_context"] = preview_context + return "detail callback" + + monkeypatch.setattr( + mod_sampling, + "prepare_detail_preview_callback", + fake_prepare_detail_preview_callback, + ) + + model = FakeModel() + assert mod_sampling._sampling_callback(model, 4, context) == "detail callback" + assert calls == {"model": model, "steps": 4, "preview_context": context} + + +class FakeModel: + """Provide the ModelPatcher methods used by the runtime.""" + + def __init__( + self, + model_options: dict[str, Any] | None = None, + ) -> None: + """Create a fake model patcher.""" + + self.load_device = torch.device("cpu") + self.model_options = {} if model_options is None else model_options + self.wrapper: Any = None + self.model_sampling = object() + + def clone(self) -> FakeModel: + """Return a cloned model with copied options.""" + + return FakeModel(self.model_options.copy()) + + def set_model_unet_function_wrapper(self, wrapper: object) -> None: + """Capture the installed model function wrapper.""" + + self.wrapper = wrapper + self.model_options["model_function_wrapper"] = wrapper + + def get_model_object(self, name: str) -> object: + """Return the requested fake model object.""" + + assert name == "model_sampling" + return self.model_sampling + + +class FakeSampler: + """Represent a resolved sampler in tests.""" + + def sample(self, *args: object, **kwargs: object) -> object: + """Provide ComfyUI's sampler protocol.""" + + del args, kwargs + return None + + +def test_model_wrapper_tiles_input_conditioning_and_transformer_options() -> None: + """The wrapper tiles latents, conditioning tensors, timesteps, and metadata.""" + + model = FakeModel() + wrapped_model, plan = mod_sampling.clone_model_with_mixture_of_diffusers( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=2, + ) + calls: list[dict[str, Any]] = [] + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **c: object, + ) -> torch.Tensor: + """Record tiled model calls and return deterministic output.""" + + calls.append({"x": x, "timestep": timestep, "c": c}) + return x + timestep.reshape((-1, 1, 1, 1)) + + x = torch.zeros((2, 1, 4, 8), dtype=torch.float32) + timestep = torch.tensor([0.5, 0.75], dtype=torch.float32) + c_concat = torch.arange(64, dtype=torch.float32).reshape((2, 1, 4, 8)) + args = { + "input": x, + "timestep": timestep, + "cond_or_uncond": [0, 1], + "c": { + "c_crossattn": torch.ones((2, 3, 1), dtype=torch.float32), + "c_concat": c_concat, + "transformer_options": { + "cond_or_uncond": [0, 1], + "uuids": ["positive", "negative"], + "sigmas": timestep, + "sample_sigmas": torch.tensor([1.0, 0.0]), + }, + }, + } + + output = wrapped_model.wrapper(apply_model, args) + + assert output.shape == x.shape + assert torch.equal( + output[:, :, :, :4], x[:, :, :, :4] + timestep.reshape(2, 1, 1, 1) + ) + assert torch.equal( + output[:, :, :, 4:], x[:, :, :, 4:] + timestep.reshape(2, 1, 1, 1) + ) + assert plan.tile_batch_size == 2 + assert len(calls) == 1 + assert calls[0]["x"].shape == (4, 1, 4, 4) + assert torch.equal(calls[0]["timestep"], torch.tensor([0.5, 0.75, 0.5, 0.75])) + assert calls[0]["c"]["c_crossattn"].shape == (4, 3, 1) + assert calls[0]["c"]["c_concat"].shape == (4, 1, 4, 4) + tiled_options = calls[0]["c"]["transformer_options"] + assert tiled_options["cond_or_uncond"] == [0, 1, 0, 1] + assert tiled_options["uuids"] == [ + "positive", + "negative", + "positive", + "negative", + ] + assert torch.equal(tiled_options["sigmas"], calls[0]["timestep"]) + assert torch.equal(tiled_options["sample_sigmas"], torch.tensor([1.0, 0.0])) + + +def test_model_wrapper_tiles_singleton_depth_5d_latents() -> None: + """The wrapper tiles Anima-style BCDHW latents across spatial axes only.""" + + model = FakeModel() + wrapped_model, _plan = mod_sampling.clone_model_with_mixture_of_diffusers( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=2, + ) + calls: list[dict[str, Any]] = [] + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **c: object, + ) -> torch.Tensor: + """Record tiled model calls and return deterministic output.""" + + calls.append({"x": x, "timestep": timestep, "c": c}) + return x + timestep.reshape((-1, 1, 1, 1, 1)) + + x = torch.zeros((1, 16, 1, 4, 8), dtype=torch.float32) + timestep = torch.tensor([0.5], dtype=torch.float32) + c_concat = torch.arange(512, dtype=torch.float32).reshape((1, 16, 1, 4, 8)) + + output = wrapped_model.wrapper( + apply_model, + { + "input": x, + "timestep": timestep, + "cond_or_uncond": [0], + "c": {"c_concat": c_concat}, + }, + ) + + assert output.shape == x.shape + assert torch.allclose(output, torch.ones_like(x) * 0.5) + assert len(calls) == 1 + assert calls[0]["x"].shape == (2, 16, 1, 4, 4) + assert calls[0]["c"]["c_concat"].shape == (2, 16, 1, 4, 4) + assert torch.equal(calls[0]["timestep"], torch.tensor([0.5, 0.5])) + + +def test_model_wrapper_blends_overlapping_tiles_with_gaussian_weights() -> None: + """Overlapping tile outputs are blended with Mixture Gaussian weights.""" + + model = FakeModel() + wrapped_model, _plan = mod_sampling.clone_model_with_mixture_of_diffusers( + model, + latent_width=6, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=2, + tile_batch_size=1, + ) + call_count = 0 + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **c: object, + ) -> torch.Tensor: + """Return a distinct constant per tile call.""" + + del timestep, c + nonlocal call_count + call_count += 1 + return torch.ones_like(x) * float(call_count * 2 - 1) + + x = torch.zeros((1, 1, 4, 6), dtype=torch.float32) + output = wrapped_model.wrapper( + apply_model, + {"input": x, "timestep": torch.tensor([1.0]), "c": {}, "cond_or_uncond": [0]}, + ) + weights = gaussian_tile_weights( + 4, + 4, + device=torch.device("cpu"), + dtype=torch.float32, + ) + expected_overlap = (1.0 * weights[0, 2] + 3.0 * weights[0, 0]) / ( + weights[0, 2] + weights[0, 0] + ) + + assert call_count == 2 + assert torch.allclose(output[:, :, :, :2], torch.ones((1, 1, 4, 2))) + assert torch.allclose(output[:, :, :, 4:], torch.ones((1, 1, 4, 2)) * 3.0) + assert torch.isclose(output[0, 0, 0, 2], expected_overlap) + + +def test_model_wrapper_preserves_existing_wrapper() -> None: + """Existing model_function_wrapper is composed for each tile call.""" + + wrapper_calls = 0 + + def old_wrapper(apply_model: object, args: dict[str, object]) -> torch.Tensor: + """Record old wrapper calls and delegate.""" + + del apply_model + nonlocal wrapper_calls + wrapper_calls += 1 + return cast(torch.Tensor, args["input"]) + 10.0 + + model = FakeModel({"model_function_wrapper": old_wrapper}) + wrapped_model, _plan = mod_sampling.clone_model_with_mixture_of_diffusers( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=1, + ) + + output = wrapped_model.wrapper( + lambda x, timestep, **c: x, + { + "input": torch.zeros((1, 1, 4, 8)), + "timestep": torch.tensor([1.0]), + "c": {}, + "cond_or_uncond": [0], + }, + ) + + assert wrapper_calls == 2 + assert torch.allclose(output, torch.ones((1, 1, 4, 8)) * 10.0) + + +def test_model_wrapper_delegates_shape_mismatch_unchanged() -> None: + """Unexpected model input shapes are delegated without tiling.""" + + model = FakeModel() + wrapped_model, _plan = mod_sampling.clone_model_with_mixture_of_diffusers( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=1, + ) + calls = 0 + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **c: object, + ) -> torch.Tensor: + """Record direct fallback calls.""" + + del timestep, c + nonlocal calls + calls += 1 + return x + 5.0 + + x = torch.zeros((1, 1, 2, 2)) + output = wrapped_model.wrapper( + apply_model, + {"input": x, "timestep": torch.tensor([1.0]), "c": {}, "cond_or_uncond": [0]}, + ) + + assert calls == 1 + assert torch.allclose(output, x + 5.0) + + +def test_sample_delegates_to_comfy_sampling_with_cloned_wrapped_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Sampling mirrors KSampler flow while using a wrapped model clone.""" + + calls: dict[str, Any] = {} + model = FakeModel() + sampler = FakeSampler() + latent_samples = torch.zeros((1, 4, 4, 8), dtype=torch.float32) + fixed_noise = torch.ones_like(latent_samples) + fixed_sigmas = torch.tensor([1.0, 0.0], dtype=torch.float32) + sampled = torch.full_like(latent_samples, 0.25) + latent_image: dict[str, Any] = { + "samples": latent_samples, + "downscale_ratio_spacial": 2, + "kept": "value", + } + + def fake_resolve_sampler(sampler_name: str) -> FakeSampler: + """Record sampler resolution.""" + + calls["sampler_name"] = sampler_name + return sampler + + def fake_calculate_sigmas(**kwargs: object) -> torch.Tensor: + """Record scheduler calculation.""" + + calls["calculate_sigmas"] = kwargs + return fixed_sigmas + + def fake_fix_empty_latent_channels( + received_model: FakeModel, + samples: torch.Tensor, + downscale_ratio_spacial: int | None, + ) -> torch.Tensor: + """Record latent channel normalization.""" + + calls["fix_empty_latent_channels"] = { + "model": received_model, + "samples": samples, + "downscale_ratio_spacial": downscale_ratio_spacial, + } + return samples + + def fake_prepare_noise( + samples: torch.Tensor, + seed: int, + batch_inds: object = None, + ) -> torch.Tensor: + """Record noise preparation.""" + + calls["prepare_noise"] = { + "samples": samples, + "seed": seed, + "batch_inds": batch_inds, + } + return fixed_noise + + def fake_prepare_callback(received_model: FakeModel, steps: int) -> str: + """Record callback preparation.""" + + calls["prepare_callback"] = {"model": received_model, "steps": steps} + return "callback" + + monkeypatch.setattr(sampling_samplers, "resolve_sampler", fake_resolve_sampler) + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + fake_calculate_sigmas, + ) + monkeypatch.setattr( + comfy_sample, + "fix_empty_latent_channels", + fake_fix_empty_latent_channels, + ) + monkeypatch.setattr(comfy_sample, "prepare_noise", fake_prepare_noise) + monkeypatch.setattr(latent_preview, "prepare_callback", fake_prepare_callback) + + def fake_sample_custom( + received_model: FakeModel, + noise: torch.Tensor, + cfg: float, + received_sampler: FakeSampler, + sigmas: torch.Tensor, + positive: object, + negative: object, + latent_image: torch.Tensor, + noise_mask: torch.Tensor | None, + callback: object, + disable_pbar: bool, + seed: int, + ) -> torch.Tensor: + """Record sample_custom arguments.""" + + calls["sample_custom"] = { + "model": received_model, + "noise": noise, + "cfg": cfg, + "sampler": received_sampler, + "sigmas": sigmas, + "positive": positive, + "negative": negative, + "latent_image": latent_image, + "noise_mask": noise_mask, + "callback": callback, + "disable_pbar": disable_pbar, + "seed": seed, + } + return sampled + + monkeypatch.setattr(comfy_sample, "sample_custom", fake_sample_custom) + monkeypatch.setattr(comfy_utils, "PROGRESS_BAR_ENABLED", False) + + output = mod_sampling.sample_mixture_of_diffusers( + model=model, + seed=123, + steps=2, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + positive=[{"model_conds": {}}], + negative=[{"model_conds": {}}], + latent_image=latent_image, + denoise=1.0, + latent_tile_width=4, + latent_tile_height=4, + latent_tile_overlap=0, + latent_tile_batch_size=2, + ) + + assert output is not latent_image + assert output["samples"] is sampled + assert output["kept"] == "value" + assert "downscale_ratio_spacial" not in output + assert calls["sample_custom"]["model"] is not model + assert calls["sample_custom"]["model"].wrapper is not None + assert calls["sample_custom"]["sampler"] is sampler + assert calls["sample_custom"]["sigmas"] is fixed_sigmas + assert calls["sample_custom"]["disable_pbar"] is True + + +def test_sample_accepts_singleton_depth_5d_latent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Anima-style singleton-depth latents pass runtime validation.""" + + calls: dict[str, Any] = {} + model = FakeModel() + sampler = FakeSampler() + latent_samples = torch.zeros((1, 16, 1, 4, 8), dtype=torch.float32) + fixed_sigmas = torch.tensor([1.0, 0.0], dtype=torch.float32) + + monkeypatch.setattr( + sampling_samplers, + "resolve_sampler", + lambda _sampler_name: sampler, + ) + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + lambda **_kwargs: fixed_sigmas, + ) + monkeypatch.setattr( + comfy_sample, + "fix_empty_latent_channels", + lambda _model, samples, _downscale_ratio_spacial: samples, + ) + monkeypatch.setattr( + comfy_sample, + "prepare_noise", + lambda samples, _seed, _batch_inds=None: torch.ones_like(samples), + ) + monkeypatch.setattr(latent_preview, "prepare_callback", lambda _model, _steps: None) + monkeypatch.setattr(comfy_utils, "PROGRESS_BAR_ENABLED", True) + + def fake_sample_custom( + received_model: FakeModel, + noise: torch.Tensor, + cfg: float, + received_sampler: FakeSampler, + sigmas: torch.Tensor, + positive: object, + negative: object, + latent_image: torch.Tensor, + noise_mask: torch.Tensor | None, + callback: object, + disable_pbar: bool, + seed: int, + ) -> torch.Tensor: + """Record sample_custom arguments and return a 5D latent.""" + + del noise, cfg, received_sampler, sigmas, positive, negative, noise_mask + del callback, disable_pbar, seed + calls["model"] = received_model + calls["latent_image"] = latent_image + return latent_image + 1.0 + + monkeypatch.setattr(comfy_sample, "sample_custom", fake_sample_custom) + + output = mod_sampling.sample_mixture_of_diffusers( + model=model, + seed=123, + steps=2, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + positive=[{"model_conds": {}}], + negative=[{"model_conds": {}}], + latent_image={"samples": latent_samples}, + denoise=1.0, + latent_tile_width=4, + latent_tile_height=4, + latent_tile_overlap=0, + latent_tile_batch_size=2, + ) + + assert calls["model"].wrapper is not None + assert calls["latent_image"] is latent_samples + assert torch.equal(output["samples"], latent_samples + 1.0) + + +def test_sample_rejects_5d_latent_with_non_singleton_depth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-singleton 5D latents remain unsupported until validated explicitly.""" + + monkeypatch.setattr( + sampling_samplers, + "resolve_sampler", + lambda _sampler_name: FakeSampler(), + ) + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + lambda **_kwargs: torch.tensor([1.0, 0.0], dtype=torch.float32), + ) + + with pytest.raises(ValueError, match="singleton third axis"): + mod_sampling.sample_mixture_of_diffusers( + model=FakeModel(), + seed=1, + steps=1, + cfg=1.0, + sampler_name="euler", + scheduler="normal", + positive=[], + negative=[], + latent_image={"samples": torch.zeros((1, 16, 2, 4, 4))}, + denoise=1.0, + latent_tile_width=4, + latent_tile_height=4, + latent_tile_overlap=0, + latent_tile_batch_size=1, + ) + + +def test_sample_rejects_unsupported_conditioning() -> None: + """Regional and ControlNet conditioning fail closed in the first slice.""" + + with pytest.raises(ValueError, match="regional conditioning or ControlNet"): + mod_sampling.sample_mixture_of_diffusers( + model=FakeModel(), + seed=1, + steps=1, + cfg=1.0, + sampler_name="euler", + scheduler="normal", + positive=[{"area": (4, 4, 0, 0)}], + negative=[], + latent_image={"samples": torch.zeros((1, 4, 4, 4))}, + denoise=1.0, + latent_tile_width=4, + latent_tile_height=4, + latent_tile_overlap=0, + latent_tile_batch_size=1, + ) diff --git a/tests/test_model_catalog.py b/tests/test_model_catalog.py new file mode 100644 index 0000000..2358550 --- /dev/null +++ b/tests/test_model_catalog.py @@ -0,0 +1,95 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for grounded SAM model catalog metadata.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.runtime.model_catalog import ( + ANIMA_QWEN_TEXT_ENCODER, + ANIMA_QWEN_VAE, + BERT_ENTRY, + GROUNDING_DINO_ENTRIES, + SAM_ENTRIES, + get_grounding_dino_entry, + get_sam_entry, + grounding_dino_choices, + sam_choices, +) + + +def test_sam_catalog_exposes_layerstyle_compatible_models() -> None: + """Known SAM choices match LayerStyle-compatible labels and filenames.""" + + choices = sam_choices() + + assert "sam_hq_vit_b (379MB)" in choices + entry = get_sam_entry("sam_hq_vit_b (379MB)") + assert entry.entry_id == "sam_hq_vit_b" + assert entry.artifacts[0].filename == "sam_hq_vit_b.pth" + assert entry.artifacts[0].folder_name == "sams" + assert entry.artifacts[0].source_url.startswith("https://huggingface.co/") + + +def test_grounding_dino_catalog_has_required_artifact_pairs() -> None: + """GroundingDINO entries carry config and checkpoint artifacts.""" + + entry = get_grounding_dino_entry("GroundingDINO_SwinT_OGC (694MB)") + filenames = {artifact.filename for artifact in entry.artifacts} + + assert "GroundingDINO_SwinT_OGC.cfg.py" in filenames + assert "groundingdino_swint_ogc.pth" in filenames + assert all(artifact.folder_name == "grounding-dino" for artifact in entry.artifacts) + + +def test_catalog_choices_are_deterministic() -> None: + """Dropdown choices preserve catalog declaration order.""" + + assert sam_choices() == [entry.display_name for entry in SAM_ENTRIES] + assert grounding_dino_choices() == [ + entry.display_name for entry in GROUNDING_DINO_ENTRIES + ] + + +def test_catalog_lookup_rejects_unknown_selection() -> None: + """Unknown model selections fail with actionable context.""" + + with pytest.raises(ValueError, match="Unknown SAM model"): + get_sam_entry("not a model") + + +def test_bert_catalog_has_huggingface_snapshot_artifacts() -> None: + """BERT metadata points at the expected Hugging Face source.""" + + filenames = {artifact.filename for artifact in BERT_ENTRY.artifacts} + + assert BERT_ENTRY.source_repo == "google-bert/bert-base-uncased" + assert { + "config.json", + "tokenizer.json", + "vocab.txt", + "model.safetensors", + } <= filenames + + +def test_anima_catalog_has_trusted_auto_artifacts() -> None: + """Anima auto artifacts declare canonical folders and checksums.""" + + assert ANIMA_QWEN_TEXT_ENCODER.folder_name == "text_encoders" + assert ANIMA_QWEN_TEXT_ENCODER.filename == "qwen_3_06b_base.safetensors" + assert ANIMA_QWEN_TEXT_ENCODER.canonical_subfolder == "qwen" + assert ANIMA_QWEN_TEXT_ENCODER.source_url.endswith( + "/split_files/text_encoders/qwen_3_06b_base.safetensors" + ) + assert len(ANIMA_QWEN_TEXT_ENCODER.sha256) == 64 + + assert ANIMA_QWEN_VAE.folder_name == "vae" + assert ANIMA_QWEN_VAE.filename == "qwen_image_vae.safetensors" + assert ANIMA_QWEN_VAE.canonical_subfolder == "qwen" + assert ANIMA_QWEN_VAE.source_url.endswith( + "/split_files/vae/qwen_image_vae.safetensors" + ) + assert len(ANIMA_QWEN_VAE.sha256) == 64 diff --git a/tests/test_model_choices.py b/tests/test_model_choices.py new file mode 100644 index 0000000..0570e74 --- /dev/null +++ b/tests/test_model_choices.py @@ -0,0 +1,179 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for settings-aware loader model choices.""" + +from __future__ import annotations + +from pathlib import Path +from types import ModuleType + +import pytest + +from simple_syrup.runtime.model_choices import ( + NO_LOCAL_GROUNDING_DINO_MODELS, + NO_LOCAL_SAM_MODELS, + NO_LOCAL_VITMATTE_MODELS, + NO_LOCAL_WD14_TAGGER_MODELS, + ModelChoiceService, +) +from simple_syrup.runtime.settings import SimpleSyrupSettings + + +class FakeSettingsRepository: + """Settings repository double for model choice tests.""" + + def __init__(self, show_downloadable_models: bool) -> None: + """Store the setting value returned by `load()`.""" + + self._settings = SimpleSyrupSettings(show_downloadable_models) + + def load(self) -> SimpleSyrupSettings: + """Return the configured settings.""" + + return self._settings + + +def test_downloadable_mode_includes_catalog_entries(tmp_path: Path) -> None: + """Downloadable mode shows known catalog entries even when files are absent.""" + + service = ModelChoiceService( + FakeSettingsRepository(show_downloadable_models=True), + fake_folder_paths(tmp_path), + ) + + assert "sam_vit_b (375MB)" in service.sam_choices() + assert "GroundingDINO_SwinT_OGC (694MB)" in service.grounding_dino_choices() + assert "vitmatte-small-composition-1k" in service.vitmatte_choices() + assert "wd-eva02-large-tagger-v3" in service.wd14_tagger_choices() + + +def test_local_only_mode_returns_sentinels_when_no_models_exist( + tmp_path: Path, +) -> None: + """Local-only mode shows clear sentinel values when folders are empty.""" + + service = local_only_service(tmp_path) + + assert service.sam_choices() == [NO_LOCAL_SAM_MODELS] + assert service.grounding_dino_choices() == [NO_LOCAL_GROUNDING_DINO_MODELS] + assert service.vitmatte_choices() == [NO_LOCAL_VITMATTE_MODELS] + assert service.wd14_tagger_choices() == [NO_LOCAL_WD14_TAGGER_MODELS] + + +def test_sam_local_only_lists_installed_catalog_artifacts(tmp_path: Path) -> None: + """SAM local-only mode lists installed known checkpoint files.""" + + (tmp_path / "models" / "sams").mkdir(parents=True) + (tmp_path / "models" / "sams" / "sam_vit_b_01ec64.pth").write_bytes(b"sam") + + choices = local_only_service(tmp_path).sam_choices() + + assert choices == ["sam_vit_b (375MB)"] + + +def test_grounding_dino_local_only_requires_complete_artifacts( + tmp_path: Path, +) -> None: + """GroundingDINO local-only mode excludes partial config/checkpoint pairs.""" + + model_dir = tmp_path / "models" / "grounding-dino" + model_dir.mkdir(parents=True) + (model_dir / "GroundingDINO_SwinT_OGC.cfg.py").write_text("", encoding="utf-8") + (model_dir / "groundingdino_swint_ogc.pth").write_bytes(b"dino") + (model_dir / "GroundingDINO_SwinB.cfg.py").write_text("", encoding="utf-8") + + choices = local_only_service(tmp_path).grounding_dino_choices() + + assert choices == ["GroundingDINO_SwinT_OGC (694MB)"] + + +def test_vitmatte_local_only_lists_valid_canonical_directory( + tmp_path: Path, +) -> None: + """ViTMatte local-only mode accepts canonical SimpleSyrup directories.""" + + create_vitmatte_snapshot( + tmp_path / "models" / "vitmatte" / "vitmatte-small-composition-1k" + ) + + choices = local_only_service(tmp_path).vitmatte_choices() + + assert choices == ["vitmatte-small-composition-1k"] + + +def test_vitmatte_local_only_lists_layerstyle_directory(tmp_path: Path) -> None: + """ViTMatte local-only mode accepts LayerStyle-compatible directories.""" + + create_vitmatte_snapshot(tmp_path / "models" / "vitmatte-base-composition-1k") + + choices = local_only_service(tmp_path).vitmatte_choices() + + assert choices == ["vitmatte-base-composition-1k"] + + +def test_wd14_local_only_requires_complete_artifacts(tmp_path: Path) -> None: + """WD14 local-only mode excludes partial ONNX/CSV pairs.""" + + model_dir = tmp_path / "models" / "wd14_tagger" + model_dir.mkdir(parents=True) + (model_dir / "wd-eva02-large-tagger-v3.onnx").write_bytes(b"onnx") + (model_dir / "wd-eva02-large-tagger-v3.csv").write_text( + "name,tag,category\n", + encoding="utf-8", + ) + (model_dir / "wd-vit-tagger-v3.onnx").write_bytes(b"onnx") + + choices = local_only_service(tmp_path).wd14_tagger_choices() + + assert choices == ["wd-eva02-large-tagger-v3"] + + +@pytest.mark.parametrize( + ("selection", "message"), + [ + (NO_LOCAL_SAM_MODELS, "No local SAM models are available"), + (NO_LOCAL_GROUNDING_DINO_MODELS, "No local GroundingDINO models are available"), + (NO_LOCAL_VITMATTE_MODELS, "No local ViTMatte models are available"), + (NO_LOCAL_WD14_TAGGER_MODELS, "No local WD14 tagger models are available"), + ], +) +def test_sentinel_validation_raises_actionable_errors( + tmp_path: Path, + selection: str, + message: str, +) -> None: + """Sentinel dropdown selections fail before loader resolution work.""" + + service = local_only_service(tmp_path) + + with pytest.raises(ValueError, match=message): + service.reject_sentinel(selection) + + +def local_only_service(tmp_path: Path) -> ModelChoiceService: + """Create a local-only choice service rooted in a temporary model folder.""" + + return ModelChoiceService( + FakeSettingsRepository(show_downloadable_models=False), + fake_folder_paths(tmp_path), + ) + + +def fake_folder_paths(tmp_path: Path) -> ModuleType: + """Create a minimal fake Comfy folder_paths module.""" + + module = ModuleType("folder_paths") + module.models_dir = str(tmp_path / "models") # type: ignore[attr-defined] + module.folder_names_and_paths = {} # type: ignore[attr-defined] + return module + + +def create_vitmatte_snapshot(path: Path) -> None: + """Create the minimal file set required for a valid ViTMatte directory.""" + + path.mkdir(parents=True) + (path / "config.json").write_text("{}", encoding="utf-8") + (path / "preprocessor_config.json").write_text("{}", encoding="utf-8") + (path / "model.safetensors").write_bytes(b"vitmatte") diff --git a/tests/test_model_device_manager.py b/tests/test_model_device_manager.py new file mode 100644 index 0000000..86bb390 --- /dev/null +++ b/tests/test_model_device_manager.py @@ -0,0 +1,184 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for SimpleSyrup's shared torch model device manager.""" + +from __future__ import annotations + +import sys +from types import ModuleType +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.runtime.model_device_manager import ( + TorchModelDeviceManager, + external_model_inference, +) + + +class RecordingModel: + """Small PyTorch-style model double.""" + + def __init__(self, device: str | None = None) -> None: + """Create a model that records device movement.""" + + self.device = torch.device(device) if device is not None else None + self.to_calls: list[str] = [] + self.eval_calls = 0 + + def to(self, device: str | torch.device) -> None: + """Record model movement.""" + + self.device = torch.device(device) + self.to_calls.append(str(device)) + + def eval(self) -> None: + """Record eval mode selection.""" + + self.eval_calls += 1 + + +def test_manager_cpu_policy_does_not_call_comfy_gpu_loader() -> None: + """CPU inference keeps the model on CPU without Comfy GPU loading.""" + + model = RecordingModel() + managed = TorchModelDeviceManager().manage(model, "model", "source") + + with TorchModelDeviceManager().inference(managed, "cpu") as loaded: + assert loaded.device == torch.device("cpu") + assert loaded.model is model + + assert model.to_calls == ["cpu"] + assert model.eval_calls >= 2 + + +def test_manager_auto_policy_uses_comfy_model_patcher( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto inference loads owned models through Comfy's model manager.""" + + state: dict[str, object] = {"loaded": []} + + class FakeModelPatcher: + """Fake Comfy ModelPatcher boundary.""" + + def __init__( + self, + model: object, + load_device: torch.device, + offload_device: torch.device, + ) -> None: + """Record patcher construction and mimic Comfy's device attribute.""" + + self.model = model + self.load_device = load_device + self.offload_device = offload_device + cast(Any, model).device = load_device + + comfy = ModuleType("comfy") + model_management = ModuleType("comfy.model_management") + model_patcher = ModuleType("comfy.model_patcher") + model_management.get_torch_device = lambda: torch.device("cuda:0") # type: ignore[attr-defined] + model_management.text_encoder_offload_device = lambda: torch.device("cpu") # type: ignore[attr-defined] + model_management.load_model_gpu = lambda patcher: state["loaded"].append(patcher) # type: ignore[attr-defined] + model_patcher.ModelPatcher = FakeModelPatcher # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "comfy", comfy) + monkeypatch.setitem(sys.modules, "comfy.model_management", model_management) + monkeypatch.setitem(sys.modules, "comfy.model_patcher", model_patcher) + + model = RecordingModel("cpu") + manager = TorchModelDeviceManager() + managed = manager.manage(model, "model", "source") + + with manager.inference(managed, "auto") as loaded: + assert loaded.device == torch.device("cuda:0") + + loaded_patchers = state["loaded"] + assert isinstance(loaded_patchers, list) + assert len(loaded_patchers) == 1 + assert managed.patcher is loaded_patchers[0] + + +def test_manager_auto_policy_bypasses_patcher_for_read_only_device_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Models like SAM with read-only `device` properties use bounded `.to(...)`.""" + + class ReadOnlyDeviceModel: + """PyTorch-like model with a read-only device property.""" + + def __init__(self) -> None: + """Create a model on CPU.""" + + self._device = torch.device("cpu") + self.to_calls: list[str] = [] + self.eval_calls = 0 + + @property + def device(self) -> torch.device: + """Return the current device without allowing assignment.""" + + return self._device + + def to(self, device: str | torch.device) -> None: + """Record model movement.""" + + self._device = torch.device(device) + self.to_calls.append(str(device)) + + def eval(self) -> None: + """Record eval mode selection.""" + + self.eval_calls += 1 + + state: dict[str, object] = {"loaded": [], "emptied": 0} + + comfy = ModuleType("comfy") + model_management = ModuleType("comfy.model_management") + model_patcher = ModuleType("comfy.model_patcher") + model_management.get_torch_device = lambda: torch.device("cuda:0") # type: ignore[attr-defined] + model_management.text_encoder_offload_device = lambda: torch.device("cpu") # type: ignore[attr-defined] + model_management.load_model_gpu = lambda patcher: state["loaded"].append(patcher) # type: ignore[attr-defined] + model_management.soft_empty_cache = lambda: state.__setitem__("emptied", 1) # type: ignore[attr-defined] + model_patcher.ModelPatcher = object # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "comfy", comfy) + monkeypatch.setitem(sys.modules, "comfy.model_management", model_management) + monkeypatch.setitem(sys.modules, "comfy.model_patcher", model_patcher) + + model = ReadOnlyDeviceModel() + manager = TorchModelDeviceManager() + managed = manager.manage(model, "sam", "source") + + with manager.inference(managed, "auto") as loaded: + assert loaded.device == torch.device("cuda:0") + assert model.device == torch.device("cuda:0") + + assert state["loaded"] == [] + assert state["emptied"] == 1 + assert managed.patcher is None + assert model.device == torch.device("cpu") + assert model.to_calls == ["cuda:0", "cpu"] + + +def test_external_model_inference_restores_original_device( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """External raw model movement is bounded to the inference context.""" + + comfy = ModuleType("comfy") + model_management = ModuleType("comfy.model_management") + model_management.get_torch_device = lambda: torch.device("cuda:0") # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "comfy", comfy) + monkeypatch.setitem(sys.modules, "comfy.model_management", model_management) + + model = RecordingModel("cpu") + + with external_model_inference(model, "auto") as loaded: + assert loaded.device == torch.device("cuda:0") + assert model.device == torch.device("cuda:0") + + assert model.device == torch.device("cpu") + assert model.to_calls == ["cuda:0", "cpu"] diff --git a/tests/test_model_downloads.py b/tests/test_model_downloads.py new file mode 100644 index 0000000..5d5651f --- /dev/null +++ b/tests/test_model_downloads.py @@ -0,0 +1,223 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for model artifact downloads and progress reporting.""" + +from __future__ import annotations + +import hashlib +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from types import TracebackType + +import pytest + +from simple_syrup.runtime.model_downloads import DownloadRequest, ModelDownloader + + +@dataclass +class RecordingProgress: + """Progress reporter double for download tests.""" + + starts: list[tuple[str, int | None]] = field(default_factory=list) + advances: list[tuple[int, int | None]] = field(default_factory=list) + finishes: int = 0 + + def start(self, label: str, total: int | None) -> None: + """Record progress start.""" + + self.starts.append((label, total)) + + def advance(self, current: int, total: int | None) -> None: + """Record progress update.""" + + self.advances.append((current, total)) + + def finish(self) -> None: + """Record progress finish.""" + + self.finishes += 1 + + +class FakeResponse: + """Small streaming response double.""" + + def __init__(self, chunks: list[bytes], total: int | None = None) -> None: + """Create a response from byte chunks.""" + + self._chunks = chunks + self.headers = {} + if total is not None: + self.headers["Content-Length"] = str(total) + + def __enter__(self) -> FakeResponse: + """Enter response context.""" + + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Exit response context.""" + + def read(self, _size: int) -> bytes: + """Return the next chunk.""" + + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +def test_downloader_streams_file_and_reports_progress( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Downloader writes to destination and reports byte progress.""" + + content = [b"abc", b"def"] + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda _url, timeout: FakeResponse(content, total=6), + ) + progress = RecordingProgress() + destination = tmp_path / "models" / "sams" / "model.pth" + + result = ModelDownloader().download( + DownloadRequest( + source_url="https://example.invalid/model.pth", + destination_path=destination, + expected_folder=tmp_path / "models" / "sams", + description="test model", + ), + progress, + ) + + assert destination.read_bytes() == b"abcdef" + assert result.bytes_downloaded == 6 + assert progress.starts == [("Downloading test model", 6)] + assert progress.advances[-1] == (6, 6) + assert progress.finishes == 1 + + +def test_downloader_verifies_expected_sha256( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Downloader verifies the final artifact checksum before publishing.""" + + content = [b"abc", b"def"] + expected_sha256 = hashlib.sha256(b"abcdef").hexdigest() + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda _url, timeout: FakeResponse(content, total=6), + ) + destination = tmp_path / "models" / "sams" / "model.pth" + + result = ModelDownloader().download( + DownloadRequest( + source_url="https://example.invalid/model.pth", + destination_path=destination, + expected_folder=destination.parent, + description="test model", + expected_sha256=expected_sha256, + ) + ) + + assert result.path == destination + assert destination.read_bytes() == b"abcdef" + + +def test_downloader_removes_partial_file_on_checksum_mismatch( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Checksum failures do not leave incomplete download files behind.""" + + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda _url, timeout: FakeResponse([b"wrong"], total=5), + ) + destination = tmp_path / "models" / "sams" / "model.pth" + + with pytest.raises(ValueError, match="checksum mismatch"): + ModelDownloader().download( + DownloadRequest( + source_url="https://example.invalid/model.pth", + destination_path=destination, + expected_folder=destination.parent, + description="test model", + expected_sha256=hashlib.sha256(b"expected").hexdigest(), + ) + ) + + assert not destination.exists() + assert not destination.with_name("model.pth.part").exists() + + +def test_downloader_skips_existing_file(tmp_path: Path) -> None: + """Existing complete files are not downloaded again.""" + + destination = tmp_path / "models" / "sams" / "model.pth" + destination.parent.mkdir(parents=True) + destination.write_bytes(b"existing") + + result = ModelDownloader().download( + DownloadRequest( + source_url="https://example.invalid/model.pth", + destination_path=destination, + expected_folder=destination.parent, + description="test model", + ) + ) + + assert result.skipped_existing is True + assert result.bytes_downloaded == 0 + + +def test_downloader_removes_partial_file_on_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Failed downloads clean up their .part file.""" + + def fail_urlopen(_url: str, timeout: int) -> FakeResponse: + raise OSError("network failed") + + monkeypatch.setattr(urllib.request, "urlopen", fail_urlopen) + destination = tmp_path / "models" / "sams" / "model.pth" + + with pytest.raises(OSError, match="network failed"): + ModelDownloader().download( + DownloadRequest( + source_url="https://example.invalid/model.pth", + destination_path=destination, + expected_folder=destination.parent, + description="test model", + ) + ) + + assert not destination.with_name("model.pth.part").exists() + + +def test_downloader_rejects_destination_outside_expected_folder( + tmp_path: Path, +) -> None: + """Downloader fails closed when destination escapes the model folder.""" + + with pytest.raises(ValueError, match="outside"): + ModelDownloader().download( + DownloadRequest( + source_url="https://example.invalid/model.pth", + destination_path=tmp_path / "elsewhere" / "model.pth", + expected_folder=tmp_path / "models", + description="test model", + ) + ) diff --git a/tests/test_model_folders.py b/tests/test_model_folders.py new file mode 100644 index 0000000..79657bd --- /dev/null +++ b/tests/test_model_folders.py @@ -0,0 +1,92 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for ComfyUI model folder registration helpers.""" + +from __future__ import annotations + +from pathlib import Path + +from simple_syrup.runtime.model_folders import ( + get_model_folder_paths, + nonrecursive_model_files, + register_required_model_folders, + resolve_model_file, +) +from test_helpers import FakeFolderPaths + + +def test_register_required_model_folders_adds_missing_folders(tmp_path: Path) -> None: + """SimpleSyrup registers required masking model folders when absent.""" + + fake = FakeFolderPaths(tmp_path) + + register_required_model_folders(fake) + + assert fake.folder_names_and_paths["sams"][0] == [str(tmp_path / "sams")] + assert fake.folder_names_and_paths["grounding-dino"][0] == [ + str(tmp_path / "grounding-dino") + ] + assert fake.folder_names_and_paths["vitmatte"][0] == [str(tmp_path / "vitmatte")] + assert fake.folder_names_and_paths["wd14_tagger"][0] == [ + str(tmp_path / "wd14_tagger") + ] + assert fake.folder_names_and_paths["wd14_tagger"][1] == {".onnx", ".csv"} + + +def test_get_model_folder_paths_includes_registered_and_fallback( + tmp_path: Path, +) -> None: + """Resolver preserves registered paths and appends the conventional fallback.""" + + fake = FakeFolderPaths(tmp_path) + fake.folder_names_and_paths["sams"] = ([str(tmp_path / "custom_sams")], {".pth"}) + + paths = get_model_folder_paths("sams", fake) + + assert paths == [tmp_path / "custom_sams", tmp_path / "sams"] + + +def test_resolve_model_file_finds_registered_file(tmp_path: Path) -> None: + """Model file lookup searches registered folders.""" + + fake = FakeFolderPaths(tmp_path) + folder = tmp_path / "sams" + folder.mkdir() + model_path = folder / "sam_vit_b_01ec64.pth" + model_path.write_bytes(b"model") + register_required_model_folders(fake) + + assert resolve_model_file("sams", "sam_vit_b_01ec64.pth", fake) == model_path + + +def test_nonrecursive_model_files_does_not_walk_subdirectories(tmp_path: Path) -> None: + """Bounded discovery scans only direct children.""" + + fake = FakeFolderPaths(tmp_path) + folder = tmp_path / "sams" + nested = folder / "nested" + nested.mkdir(parents=True) + (folder / "sam_vit_b_01ec64.pth").write_bytes(b"model") + (nested / "sam_vit_h_4b8939.pth").write_bytes(b"model") + register_required_model_folders(fake) + + assert nonrecursive_model_files("sams", fake) == ["sam_vit_b_01ec64.pth"] + + +def test_nonrecursive_model_files_uses_wd14_extensions(tmp_path: Path) -> None: + """WD14 discovery includes ONNX and CSV files without accepting unrelated files.""" + + fake = FakeFolderPaths(tmp_path) + folder = tmp_path / "wd14_tagger" + folder.mkdir() + (folder / "wd-eva02-large-tagger-v3.onnx").write_bytes(b"onnx") + (folder / "wd-eva02-large-tagger-v3.csv").write_text("", encoding="utf-8") + (folder / "ignored.pth").write_bytes(b"model") + register_required_model_folders(fake) + + assert nonrecursive_model_files("wd14_tagger", fake) == [ + "wd-eva02-large-tagger-v3.csv", + "wd-eva02-large-tagger-v3.onnx", + ] diff --git a/tests/test_model_instance_cache.py b/tests/test_model_instance_cache.py new file mode 100644 index 0000000..f92243f --- /dev/null +++ b/tests/test_model_instance_cache.py @@ -0,0 +1,123 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for process-level model instance cache mechanics.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from simple_syrup.runtime.model_instance_cache import ModelInstanceCache + + +@dataclass(frozen=True) +class FakeCacheKey: + """Identify fake loaded objects in cache tests.""" + + value: str + + +class LoadFailure(RuntimeError): + """Distinct exception raised by fake loaders.""" + + +def test_get_or_load_calls_loader_on_first_miss() -> None: + """A cache miss stores and returns the loader result.""" + + loaded = object() + calls = 0 + + def load() -> object: + """Return a fixed fake loaded model.""" + + nonlocal calls + calls += 1 + return loaded + + cache: ModelInstanceCache[FakeCacheKey, object] = ModelInstanceCache() + + assert cache.get_or_load(FakeCacheKey("model"), load) is loaded + assert calls == 1 + + +def test_get_or_load_reuses_cached_instance() -> None: + """A cache hit returns the original object without loading again.""" + + first = object() + calls = 0 + + def load() -> object: + """Return a new fake loaded model.""" + + nonlocal calls + calls += 1 + return first + + cache: ModelInstanceCache[FakeCacheKey, object] = ModelInstanceCache() + + assert cache.get_or_load(FakeCacheKey("model"), load) is first + assert cache.get_or_load(FakeCacheKey("model"), load) is first + assert calls == 1 + + +def test_get_or_load_separates_different_keys() -> None: + """Distinct keys keep distinct loaded instances.""" + + calls = 0 + + def load() -> object: + """Return a unique fake loaded model.""" + + nonlocal calls + calls += 1 + return object() + + cache: ModelInstanceCache[FakeCacheKey, object] = ModelInstanceCache() + + first = cache.get_or_load(FakeCacheKey("first"), load) + second = cache.get_or_load(FakeCacheKey("second"), load) + + assert second is not first + assert calls == 2 + + +def test_get_or_load_propagates_load_failures() -> None: + """Loader exceptions surface to callers.""" + + def load() -> object: + """Raise a fake loading failure.""" + + raise LoadFailure("failed") + + cache: ModelInstanceCache[FakeCacheKey, object] = ModelInstanceCache() + + with pytest.raises(LoadFailure, match="failed"): + cache.get_or_load(FakeCacheKey("model"), load) + + +def test_get_or_load_does_not_cache_failed_loads() -> None: + """A failed load leaves the key available for a later successful load.""" + + calls = 0 + loaded = object() + + def load() -> object: + """Fail once, then return a fake loaded model.""" + + nonlocal calls + calls += 1 + if calls == 1: + raise LoadFailure("failed") + return loaded + + cache: ModelInstanceCache[FakeCacheKey, object] = ModelInstanceCache() + key = FakeCacheKey("model") + + with pytest.raises(LoadFailure, match="failed"): + cache.get_or_load(key, load) + + assert cache.get_or_load(key, load) is loaded + assert calls == 2 diff --git a/tests/test_model_metadata.py b/tests/test_model_metadata.py new file mode 100644 index 0000000..5b7b4cc --- /dev/null +++ b/tests/test_model_metadata.py @@ -0,0 +1,33 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for grounded SAM model metadata.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from simple_syrup.runtime.model_metadata import GroundedSAMModelMetadata +from test_helpers import FakeFolderPaths + + +def test_model_metadata_reports_source_and_expected_paths(tmp_path: Path) -> None: + """Metadata includes catalog source URLs and local path diagnostics.""" + + metadata = GroundedSAMModelMetadata( + folder_paths_module=FakeFolderPaths(tmp_path) + ).describe_selection( + "sam_vit_b (375MB)", + "GroundingDINO_SwinT_OGC (694MB)", + ) + + payload = json.loads(metadata) + assert payload["sam"]["id"] == "sam_vit_b" + assert payload["grounding_dino"]["id"] == "groundingdino_swint_ogc" + assert "sam_vit_b_01ec64.pth" in metadata + assert "groundingdino_swint_ogc.pth" in metadata + assert "text_encoder_choices" in payload + assert payload["vitmatte"][0]["id"] == "vitmatte-small-composition-1k" + assert "vitmatte-base-composition-1k" in metadata diff --git a/tests/test_multidiffusion_sampling.py b/tests/test_multidiffusion_sampling.py new file mode 100644 index 0000000..7562840 --- /dev/null +++ b/tests/test_multidiffusion_sampling.py @@ -0,0 +1,783 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for MultiDiffusion ComfyUI sampling runtime.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.domain.segs import CropRegion +from simple_syrup.runtime import ( + multidiffusion_sampling, + sampling_samplers, + sampling_schedulers, +) +from simple_syrup.runtime.detail_previews import DetailPreviewContext + +comfy_sample = multidiffusion_sampling._comfy_sample() +comfy_utils = multidiffusion_sampling._comfy_utils() +latent_preview = multidiffusion_sampling._latent_preview() + + +def _preview_context() -> DetailPreviewContext: + """Return a minimal detail preview context for runtime callback tests.""" + + return DetailPreviewContext( + image=torch.ones((1, 8, 8, 3), dtype=torch.float32), + work_region=CropRegion(2, 2, 6, 6), + work_mask=torch.ones((4, 4), dtype=torch.float32), + ) + + +def test_sampling_callback_uses_generic_preview_without_detail_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """KSampler-style MultiDiffusion keeps the generic latent preview path.""" + + monkeypatch.setattr( + latent_preview, + "prepare_callback", + lambda _model, _steps: "generic callback", + ) + + assert ( + multidiffusion_sampling._sampling_callback(FakeModel(), 4, None) + == "generic callback" + ) + + +def test_sampling_callback_uses_detail_preview_with_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Detailer MultiDiffusion uses the shared detail preview callback.""" + + context = _preview_context() + calls: dict[str, object] = {} + + def fake_prepare_detail_preview_callback( + model: FakeModel, + steps: int, + preview_context: DetailPreviewContext, + ) -> str: + """Record detail preview callback preparation.""" + + calls["model"] = model + calls["steps"] = steps + calls["preview_context"] = preview_context + return "detail callback" + + monkeypatch.setattr( + multidiffusion_sampling, + "prepare_detail_preview_callback", + fake_prepare_detail_preview_callback, + ) + + model = FakeModel() + assert ( + multidiffusion_sampling._sampling_callback(model, 4, context) + == "detail callback" + ) + assert calls == {"model": model, "steps": 4, "preview_context": context} + + +class FakeModel: + """Provide the ModelPatcher methods used by the runtime.""" + + def __init__( + self, + model_options: dict[str, Any] | None = None, + ) -> None: + """Create a fake model patcher.""" + + self.load_device = torch.device("cpu") + self.model_options = {} if model_options is None else model_options + self.wrapper: Any = None + self.model_sampling = object() + + def clone(self) -> FakeModel: + """Return a cloned model with copied options.""" + + return FakeModel(self.model_options.copy()) + + def set_model_unet_function_wrapper(self, wrapper: object) -> None: + """Capture the installed model function wrapper.""" + + self.wrapper = wrapper + self.model_options["model_function_wrapper"] = wrapper + + def get_model_object(self, name: str) -> object: + """Return the requested fake model object.""" + + assert name == "model_sampling" + return self.model_sampling + + +class FakeSampler: + """Represent a resolved sampler in tests.""" + + def sample(self, *args: object, **kwargs: object) -> object: + """Provide ComfyUI's sampler protocol.""" + + del args, kwargs + return None + + +def test_clone_model_with_multidiffusion_installs_wrapper() -> None: + """The runtime clones the model and installs a MultiDiffusion wrapper.""" + + model = FakeModel() + + wrapped_model, plan = multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=2, + ) + + assert wrapped_model is not model + assert isinstance( + wrapped_model.wrapper, + multidiffusion_sampling.MultiDiffusionModelWrapper, + ) + assert plan.tile_batch_size == 2 + + +def test_clone_model_rejects_non_callable_existing_wrapper() -> None: + """Existing wrapper metadata must be callable.""" + + model = FakeModel({"model_function_wrapper": object()}) + + with pytest.raises(ValueError, match="Existing model_function_wrapper"): + multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=1, + ) + + +def test_model_wrapper_preserves_existing_wrapper() -> None: + """Existing model_function_wrapper is composed for each tile call.""" + + wrapper_calls = 0 + + def old_wrapper(apply_model: object, args: dict[str, object]) -> torch.Tensor: + """Record old wrapper calls and delegate.""" + + del apply_model + nonlocal wrapper_calls + wrapper_calls += 1 + return cast(torch.Tensor, args["input"]) + 10.0 + + model = FakeModel({"model_function_wrapper": old_wrapper}) + wrapped_model, _plan = multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=1, + ) + + output = wrapped_model.wrapper( + lambda x, timestep, **c: x, + { + "input": torch.zeros((1, 1, 4, 8)), + "timestep": torch.tensor([1.0]), + "c": {}, + "cond_or_uncond": [0], + }, + ) + + assert wrapper_calls == 2 + assert torch.allclose(output, torch.ones((1, 1, 4, 8)) * 10.0) + + +def test_model_wrapper_delegates_shape_mismatch_unchanged() -> None: + """Unexpected model input spatial shapes are delegated without tiling.""" + + model = FakeModel() + wrapped_model, _plan = multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=1, + ) + calls = 0 + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **c: object, + ) -> torch.Tensor: + """Record direct fallback calls.""" + + del timestep, c + nonlocal calls + calls += 1 + return x + 5.0 + + x = torch.zeros((1, 1, 2, 2)) + output = wrapped_model.wrapper( + apply_model, + {"input": x, "timestep": torch.tensor([1.0]), "c": {}, "cond_or_uncond": [0]}, + ) + + assert calls == 1 + assert torch.allclose(output, x + 5.0) + + +def test_model_wrapper_delegates_single_tile_plan_unchanged() -> None: + """Plans with one tile preserve the reference extension's no-op behavior.""" + + model = FakeModel() + wrapped_model, _plan = multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=4, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=1, + ) + calls = 0 + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **c: object, + ) -> torch.Tensor: + """Record direct fallback calls.""" + + del timestep, c + nonlocal calls + calls += 1 + return x + 2.0 + + x = torch.zeros((1, 1, 4, 4)) + output = wrapped_model.wrapper( + apply_model, + {"input": x, "timestep": torch.tensor([1.0]), "c": {}, "cond_or_uncond": [0]}, + ) + + assert calls == 1 + assert torch.allclose(output, x + 2.0) + + +def test_model_wrapper_tiles_input_conditioning_and_transformer_options() -> None: + """The wrapper tiles latents, conditioning tensors, timesteps, and metadata.""" + + model = FakeModel() + wrapped_model, _plan = multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=2, + ) + calls: list[dict[str, Any]] = [] + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **c: object, + ) -> torch.Tensor: + """Record tiled model calls and return deterministic output.""" + + calls.append({"x": x, "timestep": timestep, "c": c}) + return x + timestep.reshape((-1, 1, 1, 1)) + + x = torch.zeros((2, 1, 4, 8), dtype=torch.float32) + timestep = torch.tensor([0.5, 0.75], dtype=torch.float32) + c_concat = torch.arange(64, dtype=torch.float32).reshape((2, 1, 4, 8)) + args = { + "input": x, + "timestep": timestep, + "cond_or_uncond": [0, 1], + "c": { + "c_crossattn": torch.ones((2, 3, 1), dtype=torch.float32), + "c_concat": c_concat, + "transformer_options": { + "cond_or_uncond": [0, 1], + "uuids": ["positive", "negative"], + "sigmas": timestep, + "sample_sigmas": torch.tensor([1.0, 0.0]), + }, + }, + } + + output = wrapped_model.wrapper(apply_model, args) + + assert output.shape == x.shape + assert torch.equal( + output[:, :, :, :4], + x[:, :, :, :4] + timestep.reshape(2, 1, 1, 1), + ) + assert torch.equal( + output[:, :, :, 4:], + x[:, :, :, 4:] + timestep.reshape(2, 1, 1, 1), + ) + assert len(calls) == 1 + assert calls[0]["x"].shape == (4, 1, 4, 4) + assert torch.equal(calls[0]["timestep"], torch.tensor([0.5, 0.75, 0.5, 0.75])) + assert calls[0]["c"]["c_crossattn"].shape == (4, 3, 1) + assert calls[0]["c"]["c_concat"].shape == (4, 1, 4, 4) + tiled_options = calls[0]["c"]["transformer_options"] + assert tiled_options["cond_or_uncond"] == [0, 1, 0, 1] + assert tiled_options["uuids"] == [ + "positive", + "negative", + "positive", + "negative", + ] + assert torch.equal(tiled_options["sigmas"], calls[0]["timestep"]) + assert torch.equal(tiled_options["sample_sigmas"], torch.tensor([1.0, 0.0])) + + +def test_model_wrapper_tiles_singleton_depth_5d_latents() -> None: + """The wrapper tiles Anima-style BCDHW latents across spatial axes only.""" + + model = FakeModel() + wrapped_model, _plan = multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=2, + ) + calls: list[dict[str, Any]] = [] + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **c: object, + ) -> torch.Tensor: + """Record tiled model calls and return deterministic output.""" + + calls.append({"x": x, "timestep": timestep, "c": c}) + return x + timestep.reshape((-1, 1, 1, 1, 1)) + + x = torch.zeros((1, 16, 1, 4, 8), dtype=torch.float32) + timestep = torch.tensor([0.5], dtype=torch.float32) + c_concat = torch.arange(512, dtype=torch.float32).reshape((1, 16, 1, 4, 8)) + + output = wrapped_model.wrapper( + apply_model, + { + "input": x, + "timestep": timestep, + "cond_or_uncond": [0], + "c": {"c_concat": c_concat}, + }, + ) + + assert output.shape == x.shape + assert torch.allclose(output, torch.ones_like(x) * 0.5) + assert len(calls) == 1 + assert calls[0]["x"].shape == (2, 16, 1, 4, 4) + assert calls[0]["c"]["c_concat"].shape == (2, 16, 1, 4, 4) + assert torch.equal(calls[0]["timestep"], torch.tensor([0.5, 0.5])) + + +def test_model_wrapper_averages_overlapping_tiles_uniformly() -> None: + """Overlapping tile outputs are averaged by coverage count.""" + + model = FakeModel() + wrapped_model, _plan = multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=6, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=2, + tile_batch_size=1, + ) + call_count = 0 + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **c: object, + ) -> torch.Tensor: + """Return a distinct constant per tile call.""" + + del timestep, c + nonlocal call_count + call_count += 1 + return torch.ones_like(x) * float(call_count * 2 - 1) + + x = torch.zeros((1, 1, 4, 6), dtype=torch.float32) + output = wrapped_model.wrapper( + apply_model, + {"input": x, "timestep": torch.tensor([1.0]), "c": {}, "cond_or_uncond": [0]}, + ) + + assert call_count == 2 + assert torch.allclose(output[:, :, :, :2], torch.ones((1, 1, 4, 2))) + assert torch.allclose(output[:, :, :, 2:4], torch.ones((1, 1, 4, 2)) * 2.0) + assert torch.allclose(output[:, :, :, 4:], torch.ones((1, 1, 4, 2)) * 3.0) + + +def test_model_wrapper_averages_overlapping_5d_tiles_uniformly() -> None: + """Overlapping Anima-style tile outputs are averaged by coverage count.""" + + model = FakeModel() + wrapped_model, _plan = multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=6, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=2, + tile_batch_size=1, + ) + call_count = 0 + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **c: object, + ) -> torch.Tensor: + """Return a distinct constant per tile call.""" + + del timestep, c + nonlocal call_count + call_count += 1 + return torch.ones_like(x) * float(call_count * 2 - 1) + + x = torch.zeros((1, 16, 1, 4, 6), dtype=torch.float32) + output = wrapped_model.wrapper( + apply_model, + {"input": x, "timestep": torch.tensor([1.0]), "c": {}, "cond_or_uncond": [0]}, + ) + + assert call_count == 2 + assert torch.allclose(output[:, :, :, :, :2], torch.ones((1, 16, 1, 4, 2))) + assert torch.allclose( + output[:, :, :, :, 2:4], + torch.ones((1, 16, 1, 4, 2)) * 2.0, + ) + assert torch.allclose(output[:, :, :, :, 4:], torch.ones((1, 16, 1, 4, 2)) * 3.0) + + +def test_model_wrapper_rejects_unsupported_conditioning() -> None: + """Control conditioning fails closed in the first implementation.""" + + model = FakeModel() + wrapped_model, _plan = multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=2, + ) + + with pytest.raises(ValueError, match="regional conditioning or ControlNet"): + wrapped_model.wrapper( + lambda x, timestep, **c: x, + { + "input": torch.zeros((1, 1, 4, 8)), + "timestep": torch.tensor([1.0]), + "c": {"control": object()}, + "cond_or_uncond": [0], + }, + ) + + +def test_sample_rejects_unipc_before_sampler_resolution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """UniPC sampler names fail before ComfyUI sampler lookup.""" + + def fail_resolve_sampler(_sampler_name: str) -> FakeSampler: + """Fail if sampler resolution is reached.""" + + raise AssertionError("UniPC rejection should happen before sampler resolution") + + monkeypatch.setattr(sampling_samplers, "resolve_sampler", fail_resolve_sampler) + + with pytest.raises(ValueError, match="not compatible with UniPC"): + multidiffusion_sampling.sample_multidiffusion( + model=FakeModel(), + seed=1, + steps=1, + cfg=1.0, + sampler_name="uni_pc", + scheduler="normal", + positive=[], + negative=[], + latent_image={"samples": torch.zeros((1, 4, 4, 4))}, + denoise=1.0, + latent_tile_width=4, + latent_tile_height=4, + latent_tile_overlap=0, + latent_tile_batch_size=1, + ) + + +def test_sample_delegates_to_comfy_sampling_with_cloned_wrapped_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Sampling mirrors KSampler flow while using a wrapped model clone.""" + + calls: dict[str, Any] = {} + model = FakeModel() + sampler = FakeSampler() + latent_samples = torch.zeros((1, 4, 4, 8), dtype=torch.float32) + fixed_noise = torch.ones_like(latent_samples) + fixed_sigmas = torch.tensor([1.0, 0.0], dtype=torch.float32) + sampled = torch.full_like(latent_samples, 0.25) + latent_image: dict[str, Any] = { + "samples": latent_samples, + "downscale_ratio_spacial": 2, + "kept": "value", + } + + monkeypatch.setattr( + sampling_samplers, + "resolve_sampler", + lambda _sampler_name: sampler, + ) + + def fake_calculate_sigmas(**kwargs: object) -> torch.Tensor: + """Record scheduler calculation.""" + + calls["calculate_sigmas"] = kwargs + return fixed_sigmas + + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + fake_calculate_sigmas, + ) + monkeypatch.setattr( + comfy_sample, + "fix_empty_latent_channels", + lambda _model, samples, _downscale_ratio_spacial: samples, + ) + monkeypatch.setattr( + comfy_sample, + "prepare_noise", + lambda samples, _seed, _batch_inds=None: fixed_noise, + ) + monkeypatch.setattr(latent_preview, "prepare_callback", lambda _model, _steps: None) + monkeypatch.setattr(comfy_utils, "PROGRESS_BAR_ENABLED", False) + + def fake_sample_custom( + received_model: FakeModel, + noise: torch.Tensor, + cfg: float, + received_sampler: FakeSampler, + sigmas: torch.Tensor, + positive: object, + negative: object, + latent_image: torch.Tensor, + noise_mask: torch.Tensor | None, + callback: object, + disable_pbar: bool, + seed: int, + ) -> torch.Tensor: + """Record sample_custom arguments.""" + + calls["sample_custom"] = { + "model": received_model, + "noise": noise, + "cfg": cfg, + "sampler": received_sampler, + "sigmas": sigmas, + "positive": positive, + "negative": negative, + "latent_image": latent_image, + "noise_mask": noise_mask, + "callback": callback, + "disable_pbar": disable_pbar, + "seed": seed, + } + return sampled + + monkeypatch.setattr(comfy_sample, "sample_custom", fake_sample_custom) + + output = multidiffusion_sampling.sample_multidiffusion( + model=model, + seed=123, + steps=2, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + positive=[{"model_conds": {}}], + negative=[{"model_conds": {}}], + latent_image=latent_image, + denoise=1.0, + latent_tile_width=4, + latent_tile_height=4, + latent_tile_overlap=0, + latent_tile_batch_size=2, + ) + + assert output is not latent_image + assert output["samples"] is sampled + assert output["kept"] == "value" + assert "downscale_ratio_spacial" not in output + assert calls["sample_custom"]["model"] is not model + assert calls["sample_custom"]["model"].wrapper is not None + assert calls["sample_custom"]["sampler"] is sampler + assert calls["sample_custom"]["noise"] is fixed_noise + assert calls["sample_custom"]["disable_pbar"] is True + + +def test_sample_accepts_singleton_depth_5d_latent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Anima-style singleton-depth latents pass runtime validation.""" + + calls: dict[str, Any] = {} + model = FakeModel() + sampler = FakeSampler() + latent_samples = torch.zeros((1, 16, 1, 4, 8), dtype=torch.float32) + fixed_sigmas = torch.tensor([1.0, 0.0], dtype=torch.float32) + + monkeypatch.setattr( + sampling_samplers, + "resolve_sampler", + lambda _sampler_name: sampler, + ) + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + lambda **_kwargs: fixed_sigmas, + ) + monkeypatch.setattr( + comfy_sample, + "fix_empty_latent_channels", + lambda _model, samples, _downscale_ratio_spacial: samples, + ) + monkeypatch.setattr( + comfy_sample, + "prepare_noise", + lambda samples, _seed, _batch_inds=None: torch.ones_like(samples), + ) + monkeypatch.setattr(latent_preview, "prepare_callback", lambda _model, _steps: None) + monkeypatch.setattr(comfy_utils, "PROGRESS_BAR_ENABLED", True) + + def fake_sample_custom( + received_model: FakeModel, + noise: torch.Tensor, + cfg: float, + received_sampler: FakeSampler, + sigmas: torch.Tensor, + positive: object, + negative: object, + latent_image: torch.Tensor, + noise_mask: torch.Tensor | None, + callback: object, + disable_pbar: bool, + seed: int, + ) -> torch.Tensor: + """Record sample_custom arguments and return a 5D latent.""" + + del noise, cfg, received_sampler, sigmas, positive, negative, noise_mask + del callback, disable_pbar, seed + calls["model"] = received_model + calls["latent_image"] = latent_image + return latent_image + 1.0 + + monkeypatch.setattr(comfy_sample, "sample_custom", fake_sample_custom) + + output = multidiffusion_sampling.sample_multidiffusion( + model=model, + seed=123, + steps=2, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + positive=[{"model_conds": {}}], + negative=[{"model_conds": {}}], + latent_image={"samples": latent_samples}, + denoise=1.0, + latent_tile_width=4, + latent_tile_height=4, + latent_tile_overlap=0, + latent_tile_batch_size=2, + ) + + assert calls["model"].wrapper is not None + assert calls["latent_image"] is latent_samples + assert torch.equal(output["samples"], latent_samples + 1.0) + + +def test_sample_rejects_5d_latent_with_non_singleton_depth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-singleton 5D latents remain unsupported until validated explicitly.""" + + monkeypatch.setattr( + sampling_samplers, + "resolve_sampler", + lambda _sampler_name: FakeSampler(), + ) + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + lambda **_kwargs: torch.tensor([1.0, 0.0], dtype=torch.float32), + ) + + with pytest.raises(ValueError, match="singleton third axis"): + multidiffusion_sampling.sample_multidiffusion( + model=FakeModel(), + seed=1, + steps=1, + cfg=1.0, + sampler_name="euler", + scheduler="normal", + positive=[], + negative=[], + latent_image={"samples": torch.zeros((1, 16, 2, 4, 4))}, + denoise=1.0, + latent_tile_width=4, + latent_tile_height=4, + latent_tile_overlap=0, + latent_tile_batch_size=1, + ) + + +def test_sample_rejects_unsupported_conditioning() -> None: + """Regional and ControlNet conditioning fail closed in the first slice.""" + + with pytest.raises(ValueError, match="regional conditioning or ControlNet"): + multidiffusion_sampling.sample_multidiffusion( + model=FakeModel(), + seed=1, + steps=1, + cfg=1.0, + sampler_name="euler", + scheduler="normal", + positive=[{"area": (4, 4, 0, 0)}], + negative=[], + latent_image={"samples": torch.zeros((1, 4, 4, 4))}, + denoise=1.0, + latent_tile_width=4, + latent_tile_height=4, + latent_tile_overlap=0, + latent_tile_batch_size=1, + ) diff --git a/tests/test_no_external_pack_imports.py b/tests/test_no_external_pack_imports.py new file mode 100644 index 0000000..97d14e0 --- /dev/null +++ b/tests/test_no_external_pack_imports.py @@ -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 + +"""Tests that SimpleSyrup does not couple to optional node packs.""" + +from __future__ import annotations + +from pathlib import Path + + +def test_simple_syrup_does_not_import_impact_or_layerstyle() -> None: + """Runtime source should not import Impact Pack or LayerStyle modules.""" + + project_root = Path(__file__).resolve().parents[1] + source_text = "\n".join( + path.read_text(encoding="utf-8") + for path in (project_root / "simple_syrup").rglob("*.py") + ) + + forbidden = ( + "ComfyUI_LayerStyle_Advance", + "comfyui_layerstyle", + "impact.core", + "local_groundingdino", + "from impact", + "import impact", + ) + for token in forbidden: + assert token not in source_text diff --git a/tests/test_node_tooltips.py b/tests/test_node_tooltips.py new file mode 100644 index 0000000..9432669 --- /dev/null +++ b/tests/test_node_tooltips.py @@ -0,0 +1,197 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Coverage tests for ComfyUI node tooltip metadata.""" + +from __future__ import annotations + +import sys +from collections.abc import Mapping +from types import ModuleType +from typing import Any, Protocol, cast + +import pytest + +from simple_syrup.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +from simple_syrup.nodes_v3.encode_prompt_batch_with_prompt_control import ( + EncodePromptBatchWithPromptControl, +) +from simple_syrup.nodes_v3.scale_factor import ScaleFactorV3 +from simple_syrup.nodes_v3.simple_load_checkpoint import SimpleLoadCheckpointV3 +from simple_syrup.nodes_v3.tile_and_tag_segs import TileAndTagSEGSV3 +from simple_syrup.nodes_v3.wd14_tagger_loader import WD14TaggerLoaderV3 + + +class _LegacyNode(Protocol): + """Protocol for legacy ComfyUI node declarations.""" + + DESCRIPTION: str + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Return ComfyUI legacy input metadata.""" + + +class _V3Node(Protocol): + """Protocol for Comfy v3 node schema declarations.""" + + @classmethod + def define_schema(cls) -> Any: + """Return a Comfy v3 schema object.""" + + +class _FakeFolderPaths(ModuleType): + """Small folder_paths fake for deterministic v3 loader schemas.""" + + def __init__(self) -> None: + """Create deterministic ComfyUI filename lists.""" + + super().__init__("folder_paths") + self.models_dir = "E:\\ComfyUI\\models" + self.user_directory = "E:\\ComfyUI\\user" + self._files = { + "checkpoints": ["model.safetensors"], + "vae": ["manual_vae.safetensors"], + "vae_approx": [], + } + + def get_filename_list(self, folder_name: str) -> list[str]: + """Return deterministic filenames for a model folder.""" + + return self._files[folder_name] + + +def test_legacy_nodes_provide_tooltip_metadata() -> None: + """All exported legacy nodes expose descriptions and field-level help.""" + + for node_id, raw_node_class in NODE_CLASS_MAPPINGS.items(): + node_class = cast(_LegacyNode, raw_node_class) + assert node_id in NODE_DISPLAY_NAME_MAPPINGS + description = getattr(node_class, "DESCRIPTION", None) + assert isinstance(description, str) and description.strip(), ( + f"{node_id} is missing DESCRIPTION." + ) + + input_types = node_class.INPUT_TYPES() + assert isinstance(input_types, Mapping), f"{node_id} INPUT_TYPES is invalid." + for section_name in ("required", "optional", "hidden"): + section = input_types.get(section_name, {}) + assert isinstance(section, Mapping), ( + f"{node_id} {section_name} inputs must be a mapping." + ) + for field_name, declaration in section.items(): + if section_name == "hidden" and _legacy_hidden_sentinel(declaration): + continue + assert _tooltip_from_legacy_declaration(declaration), ( + f"{node_id} {section_name}.{field_name} is missing tooltip " + "metadata." + ) + + +def test_legacy_named_outputs_provide_tooltips() -> None: + """All named legacy outputs provide matching output tooltip metadata.""" + + for node_id, node_class in NODE_CLASS_MAPPINGS.items(): + return_names = getattr(node_class, "RETURN_NAMES", None) + if return_names is None: + continue + + output_tooltips = getattr(node_class, "OUTPUT_TOOLTIPS", None) + assert isinstance(output_tooltips, tuple), ( + f"{node_id} is missing OUTPUT_TOOLTIPS." + ) + assert len(output_tooltips) == len(return_names), ( + f"{node_id} OUTPUT_TOOLTIPS must match RETURN_NAMES length." + ) + for output_name, tooltip in zip(return_names, output_tooltips, strict=True): + assert isinstance(tooltip, str) and tooltip.strip(), ( + f"{node_id} output.{output_name} is missing tooltip metadata." + ) + + +@pytest.mark.parametrize( + "schema_class", + [ + SimpleLoadCheckpointV3, + ScaleFactorV3, + TileAndTagSEGSV3, + WD14TaggerLoaderV3, + EncodePromptBatchWithPromptControl, + ], +) +def test_v3_nodes_provide_tooltip_metadata( + monkeypatch: pytest.MonkeyPatch, + schema_class: type[_V3Node], +) -> None: + """All supported v3 schemas expose descriptions and field-level help.""" + + monkeypatch.setitem(sys.modules, "folder_paths", _FakeFolderPaths()) + + schema = schema_class.define_schema() + assert isinstance(schema.description, str) and schema.description.strip(), ( + f"{schema.node_id} v3 schema is missing description." + ) + for input_item in schema.inputs: + tooltip = getattr(input_item, "tooltip", None) + assert isinstance(tooltip, str) and tooltip.strip(), ( + f"{schema.node_id} input.{input_item.id} is missing tooltip metadata." + ) + for output in schema.outputs: + tooltip = getattr(output, "tooltip", None) + assert isinstance(tooltip, str) and tooltip.strip(), ( + f"{schema.node_id} output.{output.id} is missing tooltip metadata." + ) + + +def test_high_impact_tooltips_explain_direction_and_units() -> None: + """Important numeric controls explain units or practical direction.""" + + detail_node = cast( + _LegacyNode, + NODE_CLASS_MAPPINGS["SimpleSyrup.DetailSEGSByScaleFactor"], + ) + detail_inputs = detail_node.INPUT_TYPES()["required"] + denoise = _tooltip_from_legacy_declaration(detail_inputs["denoise"]) + feather = _tooltip_from_legacy_declaration(detail_inputs["feather"]) + assert "lower" in denoise.lower() and "higher" in denoise.lower() + assert "pixels" in feather.lower() + + tiled_node = cast( + _LegacyNode, + NODE_CLASS_MAPPINGS["SimpleSyrup.KSamplerTiledDiffusion"], + ) + tiled_inputs = tiled_node.INPUT_TYPES()["required"] + overlap = _tooltip_from_legacy_declaration(tiled_inputs["latent_tile_overlap"]) + batch_size = _tooltip_from_legacy_declaration( + tiled_inputs["latent_tile_batch_size"] + ) + assert "overlap" in overlap.lower() and "seams" in overlap.lower() + assert "memory" in batch_size.lower() + + +def _tooltip_from_legacy_declaration(declaration: object) -> str: + """Return a legacy ComfyUI field tooltip or an empty string.""" + + if not isinstance(declaration, tuple) or len(declaration) < 2: + return "" + options = declaration[1] + if not isinstance(options, dict): + return "" + tooltip: Any = options.get("tooltip") + if not isinstance(tooltip, str): + return "" + return tooltip.strip() + + +def _legacy_hidden_sentinel(declaration: object) -> bool: + """Return whether a declaration is a Comfy legacy hidden input sentinel.""" + + return isinstance(declaration, str) and declaration in { + "PROMPT", + "DYNPROMPT", + "EXTRA_PNGINFO", + "UNIQUE_ID", + "AUTH_TOKEN_COMFY_ORG", + "API_KEY_COMFY_ORG", + } diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py new file mode 100644 index 0000000..d5f3a24 --- /dev/null +++ b/tests/test_packaging_metadata.py @@ -0,0 +1,124 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Contract tests for package metadata and node-local requirements.""" + +from __future__ import annotations + +import json +import tomllib +from importlib import import_module +from pathlib import Path + +from packaging.requirements import Requirement + +REPO_ROOT = Path(__file__).resolve().parents[1] +COMFY_ROOT = REPO_ROOT.parents[1] +EXPECTED_RUNTIME_REQUIREMENTS = ( + "torchlanc", + "ultralytics", + "onnxruntime", + "segment-anything", + "timm", + "addict", + "yapf", + "huggingface-hub", +) + + +def _requirements(path: Path) -> tuple[Requirement, ...]: + """Parse non-comment requirement lines from a requirements file.""" + + return tuple( + Requirement(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + + +def test_requirements_are_node_local_and_do_not_duplicate_comfyui() -> None: + """Keep SimpleSyrup requirements limited to packages ComfyUI does not own.""" + + simple_syrup_requirements = _requirements(REPO_ROOT / "requirements.txt") + comfy_requirements = _requirements(COMFY_ROOT / "requirements.txt") + comfy_names = {requirement.name.lower() for requirement in comfy_requirements} + + assert ( + tuple(requirement.name.lower() for requirement in simple_syrup_requirements) + == EXPECTED_RUNTIME_REQUIREMENTS + ) + assert not { + requirement.name.lower() + for requirement in simple_syrup_requirements + if requirement.name.lower() in comfy_names + } + + +def test_pyproject_reads_runtime_dependencies_from_requirements_txt() -> None: + """Use requirements.txt as the only runtime dependency source.""" + + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text("utf-8")) + + assert pyproject["project"]["dynamic"] == ["dependencies"] + assert "dependencies" not in pyproject["project"] + assert pyproject["tool"]["setuptools"]["dynamic"]["dependencies"] == { + "file": ["requirements.txt"] + } + + +def test_pyproject_has_registry_ready_metadata() -> None: + """Require Comfy Registry and package metadata needed for publication.""" + + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text("utf-8")) + + assert pyproject["build-system"]["requires"] == ["setuptools>=77"] + assert pyproject["build-system"]["build-backend"] == "setuptools.build_meta" + assert pyproject["project"]["license"] == "AGPL-3.0-or-later" + assert pyproject["project"]["license-files"] == ["LICENSE"] + assert ( + pyproject["project"]["description"] + == "Workflow-focused ComfyUI extensions for image generation." + ) + assert pyproject["project"]["urls"]["Repository"].endswith("/SimpleSyrup") + assert pyproject["tool"]["comfy"]["PublisherId"] == "artificialsweetener" + assert pyproject["tool"]["comfy"]["DisplayName"] == "SimpleSyrup" + assert pyproject["tool"]["setuptools"]["packages"]["find"]["include"] == [ + "simple_syrup*" + ] + + +def test_frontend_package_metadata_matches_python_package_license() -> None: + """Keep frontend package metadata aligned with the Python package license.""" + + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text("utf-8")) + package = json.loads((REPO_ROOT / "package.json").read_text(encoding="utf-8")) + + assert package["private"] is True + assert package["license"] == pyproject["project"]["license"] + + +def test_package_versions_match_release_metadata() -> None: + """Keep release-managed version fields aligned across package metadata.""" + + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text("utf-8")) + package = json.loads((REPO_ROOT / "package.json").read_text(encoding="utf-8")) + package_lock = json.loads( + (REPO_ROOT / "package-lock.json").read_text(encoding="utf-8") + ) + simple_syrup = import_module("simple_syrup") + + expected_version = pyproject["project"]["version"] + + assert package["version"] == expected_version + assert package_lock["version"] == expected_version + assert package_lock["packages"][""]["version"] == expected_version + assert simple_syrup.__version__ == expected_version + + +def test_frontend_dist_bundle_is_tracked_for_comfy_serving() -> None: + """ComfyUI serves the checked-in frontend bundle from WEB_DIRECTORY.""" + + dist_bundle = REPO_ROOT / "web" / "dist" / "simple-syrup.js" + + assert dist_bundle.is_file() diff --git a/tests/test_prompt_composition.py b/tests/test_prompt_composition.py new file mode 100644 index 0000000..4069a3d --- /dev/null +++ b/tests/test_prompt_composition.py @@ -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 + +"""Tests for prompt text composition helpers.""" + +from __future__ import annotations + +from simple_syrup.domain.prompt_composition import prefix_prompt + + +def test_prefix_prompt_blank_prefix_preserves_prompt() -> None: + """Blank prefix text leaves the prompt text unchanged.""" + + assert prefix_prompt("", "blue eyes") == "blue eyes" + + +def test_prefix_prompt_non_blank_prefix_prepends_with_comma_space() -> None: + """Non-blank prefix text is prepended to non-blank prompt text.""" + + assert prefix_prompt("masterpiece", "blue eyes") == "masterpiece, blue eyes" + + +def test_prefix_prompt_non_blank_prefix_with_blank_prompt_returns_prefix() -> None: + """Blank prompt text does not add a separator after the prefix.""" + + assert prefix_prompt("masterpiece", "") == "masterpiece" + + +def test_prefix_prompt_both_blank_returns_blank() -> None: + """Blank prefix and prompt text compose to blank text.""" + + assert prefix_prompt("", "") == "" + + +def test_prefix_prompt_treats_whitespace_only_values_as_blank() -> None: + """Whitespace-only prefix and prompt values behave like blank values.""" + + assert prefix_prompt(" ", " blue eyes ") == "blue eyes" + assert prefix_prompt(" masterpiece ", " ") == "masterpiece" + + +def test_prefix_prompt_strips_edges_and_preserves_internal_commas() -> None: + """Composition trims edges while leaving prompt punctuation alone.""" + + assert ( + prefix_prompt(" high detail, best quality ", " blue eyes, looking at viewer ") + == "high detail, best quality, blue eyes, looking at viewer" + ) diff --git a/tests/test_prompt_control_availability.py b/tests/test_prompt_control_availability.py new file mode 100644 index 0000000..876689f --- /dev/null +++ b/tests/test_prompt_control_availability.py @@ -0,0 +1,98 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Prompt Control availability detection.""" + +from __future__ import annotations + +import sys +from importlib import invalidate_caches +from pathlib import Path + +import pytest + +from simple_syrup.runtime.prompt_control_availability import ( + find_prompt_control_install, + prompt_control_is_available, +) + + +def test_find_prompt_control_install_detects_sibling_checkout( + tmp_path: Path, +) -> None: + """A sibling Prompt Control checkout with nodes_lazy.py is available.""" + + package_path = tmp_path / "comfyui-prompt-control" / "prompt_control" + package_path.mkdir(parents=True) + (package_path / "nodes_lazy.py").write_text("", encoding="utf-8") + + availability = find_prompt_control_install(custom_nodes_root=tmp_path) + + assert availability.is_available is True + assert availability.root_path == tmp_path / "comfyui-prompt-control" + + +def test_find_prompt_control_install_reports_missing_sibling( + tmp_path: Path, +) -> None: + """Missing Prompt Control code is reported as unavailable.""" + + availability = find_prompt_control_install(custom_nodes_root=tmp_path) + + assert availability.is_available is False + assert availability.root_path is None + + +def test_find_prompt_control_install_does_not_import_lazy_nodes( + tmp_path: Path, +) -> None: + """Availability checks inspect files without importing nodes_lazy.""" + + package_path = tmp_path / "comfyui-prompt-control" / "prompt_control" + package_path.mkdir(parents=True) + (package_path / "nodes_lazy.py").write_text("", encoding="utf-8") + sys.modules.pop("prompt_control.nodes_lazy", None) + + availability = find_prompt_control_install(custom_nodes_root=tmp_path) + + assert availability.is_available is True + assert "prompt_control.nodes_lazy" not in sys.modules + + +def test_find_prompt_control_install_detects_python_path_package( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prompt Control can be detected when it is already on Python path.""" + + package_path = tmp_path / "prompt_control" + package_path.mkdir() + (package_path / "nodes_lazy.py").write_text("", encoding="utf-8") + sys.modules.pop("prompt_control", None) + sys.modules.pop("prompt_control.nodes_lazy", None) + monkeypatch.syspath_prepend(str(tmp_path)) + invalidate_caches() + + availability = find_prompt_control_install(custom_nodes_root=tmp_path / "missing") + + assert availability.is_available is True + assert availability.root_path == tmp_path + assert "prompt_control.nodes_lazy" not in sys.modules + + +def test_prompt_control_is_available_uses_default_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Convenience lookup returns the default resolver state.""" + + monkeypatch.setattr( + "simple_syrup.runtime.prompt_control_availability.find_prompt_control_install", + lambda: type( + "Availability", + (), + {"is_available": True, "root_path": None}, + )(), + ) + + assert prompt_control_is_available() is True diff --git a/tests/test_prompt_control_batch_graph.py b/tests/test_prompt_control_batch_graph.py new file mode 100644 index 0000000..d631134 --- /dev/null +++ b/tests/test_prompt_control_batch_graph.py @@ -0,0 +1,148 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Prompt Control prompt batch lazy graph expansion.""" + +from __future__ import annotations + +import sys +from importlib import import_module +from types import ModuleType +from typing import Any, cast + +import pytest + +from simple_syrup.runtime.prompt_control_batch_graph import ( + PROMPT_CONTROL_MISSING_MESSAGE, + PromptControlBatchGraphBuilder, +) + + +def test_prompt_control_batch_graph_matches_single_lazy_text_encode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """One positive chunk preserves Prompt Control scheduled prompt expansion.""" + + _install_fake_prompt_control(monkeypatch) + graph_utils = import_module("comfy_execution.graph_utils") + lazy_nodes = import_module("prompt_control.nodes_lazy") + graph_utils.GraphBuilder.set_default_prefix("UID", 0, 0) + expected = lazy_nodes.PCLazyTextEncodeAdvanced.execute( + clip=[0, 0], + text="[cat:dog:0.5]", + tags="", + start=0.0, + end=1.0, + num_steps=0, + ) + + graph_utils.GraphBuilder.set_default_prefix("UID", 0, 0) + output = PromptControlBatchGraphBuilder().build( + clip=[0, 0], + positive_prompt="[cat:dog:0.5]", + negative_prompt="", + separator="[SEP]", + ) + + assert output.args[0] == ["UID.0.1.1", 0] + assert output.args[1] == ["UID.0.3.1", 0] + assert output.expand is not None + for node_id, node in expected.expand.items(): + assert output.expand[node_id] == node + + +def test_prompt_control_batch_graph_builds_pack_chain_for_multiple_chunks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Multiple prompt chunks become one start node plus append nodes.""" + + _install_fake_prompt_control(monkeypatch) + graph_utils = import_module("comfy_execution.graph_utils") + graph_utils.GraphBuilder.set_default_prefix("BATCH", 0, 0) + + output = PromptControlBatchGraphBuilder().build( + clip=[0, 0], + positive_prompt="face [SEP] hair", + negative_prompt="blur [SEP] noise", + separator="[SEP]", + ) + + assert output.expand is not None + node_ids = list(output.expand) + assert len(node_ids) == len(set(node_ids)) + pack_nodes = [ + node + for node in output.expand.values() + if node["class_type"].startswith("SimpleSyrup.ConditioningBatch") + ] + assert [node["class_type"] for node in pack_nodes] == [ + "SimpleSyrup.ConditioningBatchStart", + "SimpleSyrup.ConditioningBatchAppend", + "SimpleSyrup.ConditioningBatchStart", + "SimpleSyrup.ConditioningBatchAppend", + ] + assert output.args[0] == ["BATCH.0.2.2", 0] + assert output.args[1] == ["BATCH.0.5.2", 0] + + +def test_prompt_control_batch_graph_reports_missing_prompt_control( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing Prompt Control dependency raises an actionable error.""" + + def fake_import_module(name: str) -> Any: + if name == "prompt_control.nodes_lazy": + raise ModuleNotFoundError(name) + return import_module(name) + + monkeypatch.setattr( + "simple_syrup.runtime.prompt_control_batch_graph.import_module", + fake_import_module, + ) + + with pytest.raises(RuntimeError, match="requires comfyui-prompt-control"): + PromptControlBatchGraphBuilder().build( + clip=[0, 0], + positive_prompt="face", + negative_prompt="", + separator="[SEP]", + ) + assert PROMPT_CONTROL_MISSING_MESSAGE.startswith("Encode Prompt Batch") + + +def _install_fake_prompt_control(monkeypatch: pytest.MonkeyPatch) -> None: + """Install a small Prompt Control lazy-node double for graph tests.""" + + prompt_control = ModuleType("prompt_control") + nodes_lazy = ModuleType("prompt_control.nodes_lazy") + + class FakePCLazyTextEncodeAdvanced: + """Graph-expanding stand-in for Prompt Control's lazy text encoder.""" + + @staticmethod + def execute( + clip: Any, + text: str, + tags: str, + start: float, + end: float, + num_steps: int, + ) -> Any: + """Return one lazy text encode node output.""" + + del tags, start, end, num_steps + graph_utils = import_module("comfy_execution.graph_utils") + io = import_module("comfy_api.latest").io + graph = graph_utils.GraphBuilder() + node = graph.node( + "PromptControl.PCLazyTextEncodeAdvanced", + clip=clip, + text=text, + ) + return io.NodeOutput(node.out(0), expand=graph.finalize()) + + cast(Any, nodes_lazy).PCLazyTextEncodeAdvanced = FakePCLazyTextEncodeAdvanced + cast(Any, prompt_control).nodes_lazy = nodes_lazy + monkeypatch.setitem(sys.modules, "prompt_control", prompt_control) + monkeypatch.setitem(sys.modules, "prompt_control.nodes_lazy", nodes_lazy) diff --git a/tests/test_prompt_encode_style_nodes.py b/tests/test_prompt_encode_style_nodes.py new file mode 100644 index 0000000..7d6da2f --- /dev/null +++ b/tests/test_prompt_encode_style_nodes.py @@ -0,0 +1,119 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Prompt Encode Style ComfyUI node declarations.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from simple_syrup.nodes.prompt_encode_style import PromptEncodeStyle +from simple_syrup.nodes.prompt_encode_style_and_normalization import ( + PromptEncodeStyleAndNormalization, +) + +ENCODE_STYLE_OPTIONS = [ + "A1111", + "Comfy", + "Comfy++", + "Compel", + "Down Weight", + "Perp", +] + + +def test_prompt_encode_style_node_contract_constants() -> None: + """Style-only node constants match the public ComfyUI contract.""" + + assert PromptEncodeStyle.RETURN_TYPES == ("STRING",) + assert PromptEncodeStyle.RETURN_NAMES == ("style_tag",) + assert PromptEncodeStyle.FUNCTION == "build" + assert PromptEncodeStyle.CATEGORY == "SimpleSyrup/Prompt" + + +def test_prompt_encode_style_node_declares_expected_inputs() -> None: + """Style-only node input declaration includes only encode style.""" + + input_types: dict[str, dict[str, tuple[Any, ...]]] = PromptEncodeStyle.INPUT_TYPES() + + required = input_types["required"] + + assert set(required) == {"encode_style"} + assert required["encode_style"][0] == ENCODE_STYLE_OPTIONS + assert required["encode_style"][1]["default"] == "A1111" + assert "tooltip" in required["encode_style"][1] + + +@pytest.mark.parametrize( + ("encode_style", "expected"), + [ + ("A1111", "STYLE(A1111) "), + ("Comfy++", "STYLE(comfy++) "), + ("Down Weight", "STYLE(down_weight) "), + ], +) +def test_prompt_encode_style_node_builds_style_tag( + encode_style: str, expected: str +) -> None: + """Style-only node formats Prompt Control STYLE tags from combo values.""" + + (style_tag,) = PromptEncodeStyle().build(encode_style) + + assert style_tag == expected + assert style_tag.endswith(" ") + assert not style_tag.endswith(" ") + + +def test_prompt_encode_style_and_normalization_node_contract_constants() -> None: + """Normalization node constants match the public ComfyUI contract.""" + + assert PromptEncodeStyleAndNormalization.RETURN_TYPES == ("STRING",) + assert PromptEncodeStyleAndNormalization.RETURN_NAMES == ("style_tag",) + assert PromptEncodeStyleAndNormalization.FUNCTION == "build" + assert PromptEncodeStyleAndNormalization.CATEGORY == "SimpleSyrup/Prompt" + + +def test_prompt_encode_style_and_normalization_node_declares_expected_inputs() -> None: + """Normalization node input declaration includes encode style and normalization.""" + + input_types: dict[str, dict[str, tuple[Any, ...]]] = ( + PromptEncodeStyleAndNormalization.INPUT_TYPES() + ) + + required = input_types["required"] + + assert set(required) == {"encode_style", "normalization"} + assert required["encode_style"][0] == ENCODE_STYLE_OPTIONS + assert required["encode_style"][1]["default"] == "A1111" + assert "tooltip" in required["encode_style"][1] + assert required["normalization"][0] == ["none", "length", "mean", "length+mean"] + assert required["normalization"][1]["default"] == "none" + assert "tooltip" in required["normalization"][1] + + +@pytest.mark.parametrize( + ("encode_style", "normalization", "expected"), + [ + ("A1111", "none", "STYLE(A1111) "), + ("A1111", "length", "STYLE(A1111, length) "), + ("Comfy++", "none", "STYLE(comfy++) "), + ("Comfy++", "length", "STYLE(comfy++, length) "), + ("Down Weight", "none", "STYLE(down_weight) "), + ("Perp", "length+mean", "STYLE(perp, length+mean) "), + ], +) +def test_prompt_encode_style_and_normalization_node_builds_style_tag( + encode_style: str, normalization: str, expected: str +) -> None: + """Normalization node formats Prompt Control STYLE tags from combo values.""" + + (style_tag,) = PromptEncodeStyleAndNormalization().build( + encode_style, normalization + ) + + assert style_tag == expected + assert style_tag.endswith(" ") + assert not style_tag.endswith(" ") diff --git a/tests/test_prompt_segs_with_sam_compatibility.py b/tests/test_prompt_segs_with_sam_compatibility.py new file mode 100644 index 0000000..06c4bcb --- /dev/null +++ b/tests/test_prompt_segs_with_sam_compatibility.py @@ -0,0 +1,110 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Compatibility tests for Prompt SEGS w/ SAM model socket objects.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import cast + +import pytest +import torch + +from simple_syrup.masking.prompt_segs_with_sam_service import PromptSEGSWithSAMService +from test_helpers import make_image_tensor + + +class PredictBoxesModel: + """DINO object exposing predict_boxes.""" + + def predict_boxes( + self, + image: torch.Tensor, + prompt: str, + threshold: float, + ) -> torch.Tensor: + """Return one full-image box.""" + + return torch.tensor([[0.0, 0.0, float(image.shape[1]), float(image.shape[0])]]) + + +class Wrapper: + """Impact-style SAM wrapper exposing predict.""" + + def predict( + self, + image: object, + points: list[object], + plabs: list[int], + bbox: list[float], + threshold: float, + ) -> list[torch.Tensor]: + """Return one all-white mask.""" + + return [torch.ones((2, 2), dtype=torch.float32)] + + +def test_prompt_service_accepts_impact_style_sam_model() -> None: + """Impact-style SAM_MODEL objects can produce prompt SEGS.""" + + service = PromptSEGSWithSAMService() + + result = service.prompt( + image=make_image_tensor(batch_size=1, height=2, width=2), + sam_model=SimpleNamespace(sam_wrapper=Wrapper()), + grounding_dino_model=PredictBoxesModel(), + vitmatte_model=None, + positive_prompt="face", + negative_prompt="", + confidence_threshold=0.3, + size_threshold=1, + bbox_dilation=0, + mask_dilation=0, + detail_method="GuidedFilter", + detail_erode=0, + detail_dilate=0, + black_point=0.0, + white_point=1.0, + refine_mask=False, + mask_refinement_max_size=4, + execution_device="cpu", + crop_factor=1.0, + sort_order="largest to smallest", + ) + + assert len(result[1]) == 1 + assert torch.equal( + cast(torch.Tensor, result[1][0].cropped_mask), torch.ones((2, 2)) + ) + + +def test_prompt_service_rejects_invalid_dino_model() -> None: + """Invalid DINO socket objects fail clearly.""" + + service = PromptSEGSWithSAMService() + + with pytest.raises(TypeError, match="Prompt SEGS w/ SAM"): + service.prompt( + image=make_image_tensor(batch_size=1, height=2, width=2), + sam_model=SimpleNamespace(sam_wrapper=Wrapper()), + grounding_dino_model=object(), + vitmatte_model=None, + positive_prompt="face", + negative_prompt="", + confidence_threshold=0.3, + size_threshold=1, + bbox_dilation=0, + mask_dilation=0, + detail_method="GuidedFilter", + detail_erode=0, + detail_dilate=0, + black_point=0.0, + white_point=1.0, + refine_mask=False, + mask_refinement_max_size=4, + execution_device="cpu", + crop_factor=1.0, + sort_order="largest to smallest", + ) diff --git a/tests/test_prompt_segs_with_sam_node.py b/tests/test_prompt_segs_with_sam_node.py new file mode 100644 index 0000000..05363e5 --- /dev/null +++ b/tests/test_prompt_segs_with_sam_node.py @@ -0,0 +1,362 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Prompt SEGS w/ SAM ComfyUI node declaration.""" + +from __future__ import annotations + +from typing import Any, cast + +import torch + +from simple_syrup.domain.segs import ( + SORT_ORDER_OPTIONS, + BoundingBox, + CropRegion, + Segment, +) +from simple_syrup.nodes.prompt_segs_with_sam import PromptSEGSWithSAM +from simple_syrup.services.segs_output_service import CombinedSegsResult +from test_helpers import make_image_tensor + + +def test_prompt_segs_with_sam_node_contract_constants() -> None: + """Node constants match the public ComfyUI contract.""" + + assert PromptSEGSWithSAM.RETURN_TYPES == ("SEGS", "MASK") + assert PromptSEGSWithSAM.RETURN_NAMES == ("segs", "mask") + assert PromptSEGSWithSAM.OUTPUT_IS_LIST == (True, False) + assert PromptSEGSWithSAM.OUTPUT_TOOLTIPS == ( + "Prompted regions as separate or combined SEGS based on combine_segs.", + "Combined prompted area as a standard ComfyUI mask.", + ) + assert PromptSEGSWithSAM.FUNCTION == "prompt" + assert PromptSEGSWithSAM.CATEGORY == "SimpleSyrup/Detection" + assert PromptSEGSWithSAM.DESCRIPTION == ( + "Finds prompt-matched regions with GroundingDINO, segments them with SAM, " + "and returns SEGS plus a combined mask." + ) + + +def test_prompt_segs_with_sam_node_declares_expected_inputs() -> None: + """Node input declaration includes aligned prompt SEGS controls.""" + + input_types: dict[str, dict[str, Any]] = PromptSEGSWithSAM.INPUT_TYPES() + required = input_types["required"] + optional = input_types["optional"] + + assert list(required) == [ + "image", + "sam_model", + "grounding_dino_model", + "positive_prompt", + "negative_prompt", + "confidence_threshold", + "size_threshold", + "bbox_dilation", + "mask_dilation", + "detail_method", + "detail_erode", + "detail_dilate", + "black_point", + "white_point", + "refine_mask", + "mask_refinement_max_size", + "execution_device", + "crop_factor", + "sort_order", + "combine_segs", + ] + assert set(optional) == {"vitmatte_model"} + assert "hidden" not in input_types + assert "box_threshold" not in required + assert "process_detail" not in required + assert "max_size_pixels" not in required + assert required["sam_model"][0] == "SAM_MODEL" + assert required["grounding_dino_model"][0] == "GROUNDING_DINO_MODEL,DINO_MODEL" + assert optional["vitmatte_model"][0] == "VITMATTE_MODEL" + assert required["positive_prompt"][1]["default"] == "" + assert required["positive_prompt"][1]["tooltip"] == ( + "Text describing the regions to detect." + ) + assert required["detail_method"][0] == ["GuidedFilter", "PyMatting", "VITMatte"] + assert required["sort_order"][0] == SORT_ORDER_OPTIONS + assert required["sort_order"][1]["default"] == SORT_ORDER_OPTIONS[0] + assert required["combine_segs"][0] == "BOOLEAN" + assert required["combine_segs"][1]["default"] is False + for input_declaration in required.values(): + assert "tooltip" in input_declaration[1] + assert "tooltip" in optional["vitmatte_model"][1] + + +def test_prompt_segs_with_sam_node_returns_individual_segs_when_combine_disabled() -> ( + None +): + """Node execution keeps prompted SEGS separate when combine_segs is false.""" + + image = make_image_tensor(batch_size=1, height=3, width=3) + segs = _segs(_segment("face", CropRegion(0, 0, 2, 2), 0.9)) + combined_mask = torch.ones((1, 3, 3), dtype=torch.float32) + combined = CombinedSegsResult( + segs=_segs(_segment("combined", CropRegion(0, 0, 3, 3), 1.0)), + mask=combined_mask, + ) + + class FakeService: + """Service double for node delegation.""" + + def prompt( + self, **kwargs: object + ) -> tuple[tuple[int, int], tuple[Segment, ...]]: + """Return fixed SEGS while checking key forwarded values.""" + + assert kwargs["confidence_threshold"] == 0.3 + assert kwargs["size_threshold"] == 10 + assert kwargs["refine_mask"] is True + assert kwargs["mask_refinement_max_size"] == 2048 + assert kwargs["vitmatte_model"] == {"vitmatte": "model"} + return segs + + builder = _FixedCombinedBuilder(image, segs, combined) + result = _prompt_with_fakes( + service=FakeService(), + builder=builder, + image=image, + combine_segs=False, + vitmatte_model={"vitmatte": "model"}, + ) + + assert result[0] == [(segs[0], list(segs[1]))] + assert torch.equal(cast(torch.Tensor, result[1]), combined_mask) + assert builder.call_count == 1 + + +def test_prompt_segs_with_sam_node_returns_unioned_segs_when_combine_enabled() -> None: + """Node execution emits one unioned SEGS when combine_segs is true.""" + + image = make_image_tensor(batch_size=1, height=3, width=3) + segs = _segs(_segment("face", CropRegion(0, 0, 2, 2), 0.9)) + combined = CombinedSegsResult( + segs=_segs(_segment("combined", CropRegion(0, 0, 3, 3), 1.0)), + mask=torch.ones((1, 3, 3), dtype=torch.float32), + ) + + result = _prompt_with_fakes( + service=_FakePromptService(segs), + builder=_FixedCombinedBuilder(image, segs, combined), + image=image, + combine_segs=True, + ) + + segs_list = cast(list[tuple[object, list[Segment]]], result[0]) + _header, segments = segs_list[0] + assert [segment.label for segment in segments] == ["combined"] + assert torch.equal(cast(torch.Tensor, result[1]), combined.mask) + + +def test_prompt_segs_with_sam_node_processes_image_batches_with_individual_segs() -> ( + None +): + """A batch image produces one prompted SEGS output per image.""" + + image = make_image_tensor(batch_size=2, height=3, width=3) + service = _FakeBatchPromptService() + builder = _FakeBatchCombinedBuilder() + + result = _prompt_with_fakes( + service=service, + builder=builder, + image=image, + combine_segs=False, + ) + + segs_list = cast(list[tuple[object, list[Segment]]], result[0]) + assert service.call_count == 2 + assert builder.call_count == 2 + assert [segments[0].label for _header, segments in segs_list] == [ + "image-0", + "image-1", + ] + assert cast(torch.Tensor, result[1]).shape == (2, 3, 3) + + +def test_prompt_segs_with_sam_node_processes_image_batches_with_unioned_segs() -> None: + """A batch image produces one combined SEGS output per image.""" + + image = make_image_tensor(batch_size=2, height=3, width=3) + service = _FakeBatchPromptService() + builder = _FakeBatchCombinedBuilder() + + result = _prompt_with_fakes( + service=service, + builder=builder, + image=image, + combine_segs=True, + ) + + segs_list = cast(list[tuple[object, list[Segment]]], result[0]) + assert [segments[0].label for _header, segments in segs_list] == [ + "combined-image-0", + "combined-image-1", + ] + assert builder.call_count == 2 + assert cast(torch.Tensor, result[1]).shape == (2, 3, 3) + + +class _FakePromptService: + """Service double that returns fixed SEGS.""" + + def __init__(self, segs: tuple[tuple[int, int], tuple[Segment, ...]]) -> None: + """Store the SEGS payload returned by prompt calls.""" + + self._segs = segs + + def prompt(self, **kwargs: object) -> tuple[tuple[int, int], tuple[Segment, ...]]: + """Return fixed SEGS.""" + + del kwargs + return self._segs + + +class _FakeBatchPromptService: + """Service double that records per-image prompt calls.""" + + def __init__(self) -> None: + """Create an empty call counter.""" + + self.call_count = 0 + + def prompt(self, **kwargs: object) -> tuple[tuple[int, int], tuple[Segment, ...]]: + """Return a labeled SEGS payload for one image slice.""" + + assert cast(torch.Tensor, kwargs["image"]).shape == (1, 3, 3, 3) + label = f"image-{self.call_count}" + self.call_count += 1 + return _segs(_segment(label, CropRegion(0, 0, 2, 2), 0.9)) + + +class _FixedCombinedBuilder: + """Combined-result builder double with input assertions.""" + + def __init__( + self, + expected_image: torch.Tensor, + expected_segs: tuple[tuple[int, int], tuple[Segment, ...]], + combined: CombinedSegsResult, + ) -> None: + """Store expected call arguments and the fixed return value.""" + + self._expected_image = expected_image + self._expected_segs = expected_segs + self._combined = combined + self.call_count = 0 + + def __call__( + self, + source_image: object, + source_segs: tuple[tuple[int, int], tuple[Segment, ...]], + ) -> CombinedSegsResult: + """Return the fixed combined result after checking call arguments.""" + + self.call_count += 1 + assert torch.equal(cast(torch.Tensor, source_image), self._expected_image) + assert source_segs is self._expected_segs + return self._combined + + +class _FakeBatchCombinedBuilder: + """Combined-result builder that derives labels from source SEGS.""" + + def __init__(self) -> None: + """Create an empty call counter.""" + + self.call_count = 0 + + def __call__( + self, + source_image: object, + source_segs: tuple[tuple[int, int], tuple[Segment, ...]], + ) -> CombinedSegsResult: + """Return a combined result for one image slice.""" + + self.call_count += 1 + assert cast(torch.Tensor, source_image).shape == (1, 3, 3, 3) + segment_label = source_segs[1][0].label + return CombinedSegsResult( + segs=_segs( + _segment( + f"combined-{segment_label}", + CropRegion(0, 0, 1, 1), + 1.0, + ) + ), + mask=torch.ones((1, 3, 3), dtype=torch.float32), + ) + + +def _prompt_with_fakes( + service: object, + builder: object, + image: torch.Tensor, + combine_segs: bool, + vitmatte_model: object | None = None, +) -> tuple[object, object]: + """Run the prompt node with temporary service and builder doubles.""" + + node = PromptSEGSWithSAM() + original_service = PromptSEGSWithSAM._service + original_builder = PromptSEGSWithSAM.combined_builder + PromptSEGSWithSAM._service = service # type: ignore[assignment] + PromptSEGSWithSAM.combined_builder = builder # type: ignore[assignment] + try: + return node.prompt( + image=image, + sam_model={"sam": "model"}, + grounding_dino_model={"dino": "model"}, + positive_prompt="face", + negative_prompt="", + confidence_threshold=0.3, + size_threshold=10, + bbox_dilation=0, + mask_dilation=0, + detail_method="GuidedFilter", + detail_erode=6, + detail_dilate=6, + black_point=0.15, + white_point=0.99, + refine_mask=True, + mask_refinement_max_size=2048, + execution_device="cpu", + crop_factor=3.0, + sort_order="largest to smallest", + combine_segs=combine_segs, + vitmatte_model=vitmatte_model, + ) + finally: + PromptSEGSWithSAM._service = original_service + PromptSEGSWithSAM.combined_builder = original_builder + + +def _segs(*segments: Segment) -> tuple[tuple[int, int], tuple[Segment, ...]]: + """Create native SEGS for tests.""" + + return (3, 3), tuple(segments) + + +def _segment(label: str, crop_region: CropRegion, confidence: float) -> Segment: + """Create a test segment.""" + + return Segment( + cropped_image=None, + cropped_mask=torch.ones((crop_region.height, crop_region.width)), + confidence=confidence, + crop_region=crop_region, + bbox=BoundingBox( + crop_region.left, + crop_region.top, + crop_region.right, + crop_region.bottom, + ), + label=label, + ) diff --git a/tests/test_prompt_segs_with_sam_service.py b/tests/test_prompt_segs_with_sam_service.py new file mode 100644 index 0000000..4b0f8cc --- /dev/null +++ b/tests/test_prompt_segs_with_sam_service.py @@ -0,0 +1,409 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Prompt SEGS w/ SAM application service.""" + +from __future__ import annotations + +from typing import Protocol, cast + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox, NativeSegs +from simple_syrup.masking.prompt_segs_with_sam_service import ( + PromptSegsRuntime, + PromptSEGSWithSAMService, +) +from simple_syrup.runtime.text_box_detector import TextBoxDetection +from test_helpers import make_image_tensor + + +class RecordingDetector: + """Text box detector double that records prompts.""" + + def __init__(self, detections: dict[str, tuple[TextBoxDetection, ...]]) -> None: + """Create a detector double.""" + + self.detections = detections + self.calls: list[tuple[str, float]] = [] + + def detect( + self, + grounding_dino_model: object, + image: torch.Tensor, + prompt: str, + threshold: float, + execution_device: str, + ) -> tuple[TextBoxDetection, ...]: + """Record the prompt and return configured detections.""" + + self.calls.append((prompt, threshold)) + return self.detections.get(prompt, ()) + + +class RecordingSegmenter: + """SAM segmenter double that returns configured masks.""" + + def __init__(self, masks: dict[str, list[torch.Tensor]]) -> None: + """Create a segmenter double.""" + + self.masks = masks + self.calls: list[torch.Tensor] = [] + self.call_index = 0 + + def segment_boxes( + self, + sam_model: object, + image: torch.Tensor, + boxes: torch.Tensor, + threshold: float, + execution_device: str, + ) -> tuple[torch.Tensor, ...]: + """Record boxes and return the next mask group.""" + + self.calls.append(boxes.clone()) + key = str(self.call_index) + self.call_index += 1 + return tuple(self.masks.get(key, [])) + + +class _HasMaxSizePixels(Protocol): + """Expose the refinement max-size setting used by the test double.""" + + max_size_pixels: int + + +class RecordingRefiner: + """Mask detail refiner double.""" + + def __init__(self) -> None: + """Create a refiner double.""" + + self.calls: list[int] = [] + + def refine( + self, + image: torch.Tensor, + mask: torch.Tensor, + settings: object, + vitmatte_model: object | None, + ) -> torch.Tensor: + """Record max size and return the mask.""" + + self.calls.append(int(cast(_HasMaxSizePixels, settings).max_size_pixels)) + return mask + + +def test_service_requires_positive_prompt() -> None: + """Blank positive prompts are rejected before runtime work.""" + + service = _service(RecordingDetector({}), RecordingSegmenter({})) + + with pytest.raises(ValueError, match="positive_prompt is required"): + _prompt(service, positive_prompt=" ") + + +def test_service_rejects_invalid_confidence_threshold() -> None: + """Confidence threshold must be probability-like.""" + + service = _service(RecordingDetector({}), RecordingSegmenter({})) + + with pytest.raises(ValueError, match="confidence_threshold"): + _prompt(service, confidence_threshold=1.5) + + +def test_service_rejects_invalid_size_threshold() -> None: + """Size threshold must be positive.""" + + service = _service(RecordingDetector({}), RecordingSegmenter({})) + + with pytest.raises(ValueError, match="size_threshold"): + _prompt(service, size_threshold=0) + + +def test_service_rejects_invalid_crop_factor() -> None: + """Crop factor must be compatible with SEGS crops.""" + + service = _service(RecordingDetector({}), RecordingSegmenter({})) + + for crop_factor in (0.0, 0.5): + with pytest.raises(ValueError, match="crop_factor"): + _prompt(service, crop_factor=crop_factor) + + +def test_service_skips_blank_negative_prompt() -> None: + """Blank negative prompt does not run a second detection.""" + + detector = RecordingDetector({"face": (_detection(0, 0, 2, 2, 0.8),)}) + service = _service(detector, RecordingSegmenter({"0": [_mask_square(0, 0, 2, 2)]})) + + _prompt(service, positive_prompt=" face ", negative_prompt=" ") + + assert detector.calls == [("face", 0.3)] + + +def test_service_creates_one_seg_per_positive_box() -> None: + """Multiple positive detections become multiple SEGS.""" + + detector = RecordingDetector( + { + "face": ( + _detection(0, 0, 2, 2, 0.8), + _detection(2, 2, 4, 4, 0.7), + ) + } + ) + segmenter = RecordingSegmenter( + {"0": [_mask_square(0, 0, 2, 2), _mask_square(2, 2, 4, 4)]} + ) + service = _service(detector, segmenter) + + segs = _prompt(service, positive_prompt="face", size_threshold=1) + + assert [segment.confidence for segment in segs[1]] == [0.8, 0.7] + assert [segment.label for segment in segs[1]] == ["face", "face"] + + +def test_service_subtracts_negative_prompt_from_positive_segs() -> None: + """Negative prompt masks are removed from each positive SEG.""" + + detector = RecordingDetector( + { + "person": (_detection(0, 0, 4, 4, 0.9),), + "hand": (_detection(0, 0, 2, 2, 0.9),), + } + ) + segmenter = RecordingSegmenter( + { + "0": [torch.ones((4, 4), dtype=torch.float32)], + "1": [_mask_square(0, 0, 2, 2)], + } + ) + service = _service(detector, segmenter) + + segs = _prompt( + service, + positive_prompt="person", + negative_prompt="hand", + size_threshold=1, + ) + + assert len(segs[1]) == 1 + assert torch.count_nonzero(cast(torch.Tensor, segs[1][0].cropped_mask)) == 12 + + +def test_service_discards_positive_segs_emptied_by_negative_prompt() -> None: + """Positive masks fully removed by the negative prompt are discarded.""" + + detector = RecordingDetector( + { + "person": (_detection(0, 0, 2, 2, 0.9),), + "hand": (_detection(0, 0, 2, 2, 0.9),), + } + ) + segmenter = RecordingSegmenter( + {"0": [_mask_square(0, 0, 2, 2)], "1": [_mask_square(0, 0, 2, 2)]} + ) + service = _service(detector, segmenter) + + segs = _prompt(service, positive_prompt="person", negative_prompt="hand") + + assert segs[1] == () + + +def test_service_discards_small_final_segs() -> None: + """Size threshold filters the final derived bbox.""" + + detector = RecordingDetector({"face": (_detection(0, 0, 1, 1, 0.8),)}) + service = _service( + detector, + RecordingSegmenter({"0": [_mask_square(0, 0, 1, 1)]}), + ) + + segs = _prompt(service, positive_prompt="face", size_threshold=2) + + assert segs[1] == () + + +def test_service_expands_bboxes_before_sam() -> None: + """bbox_dilation expands prompt boxes before segmentation.""" + + detector = RecordingDetector({"face": (_detection(1, 1, 3, 3, 0.8),)}) + segmenter = RecordingSegmenter({"0": [_mask_square(1, 1, 3, 3)]}) + service = _service(detector, segmenter) + + _prompt(service, positive_prompt="face", bbox_dilation=1, size_threshold=1) + + assert torch.equal( + segmenter.calls[0], + torch.tensor([[0.0, 0.0, 4.0, 4.0]], dtype=torch.float32), + ) + + +def test_service_applies_mask_dilation() -> None: + """mask_dilation morphs the final per-SEG mask.""" + + detector = RecordingDetector({"face": (_detection(1, 1, 2, 2, 0.8),)}) + service = _service( + detector, + RecordingSegmenter({"0": [_mask_square(1, 1, 2, 2)]}), + ) + + segs = _prompt( + service, + positive_prompt="face", + size_threshold=1, + mask_dilation=1, + crop_factor=1.0, + ) + + assert segs[1][0].bbox == BoundingBox(0, 0, 3, 3) + assert torch.count_nonzero(cast(torch.Tensor, segs[1][0].cropped_mask)) == 9 + + +def test_service_refine_mask_false_skips_external_refiner() -> None: + """Disabled refinement skips the ViTMatte refiner.""" + + refiner = RecordingRefiner() + detector = RecordingDetector({"face": (_detection(0, 0, 2, 2, 0.8),)}) + service = _service( + detector, + RecordingSegmenter({"0": [_mask_square(0, 0, 2, 2)]}), + refiner, + ) + + _prompt(service, positive_prompt="face", refine_mask=False) + + assert refiner.calls == [] + + +def test_service_vitmatte_refinement_receives_max_size() -> None: + """mask_refinement_max_size reaches the external refiner settings.""" + + refiner = RecordingRefiner() + detector = RecordingDetector({"face": (_detection(0, 0, 2, 2, 0.8),)}) + service = _service( + detector, + RecordingSegmenter({"0": [_mask_square(0, 0, 2, 2)]}), + refiner, + ) + + _prompt( + service, + positive_prompt="face", + detail_method="VITMatte", + refine_mask=True, + mask_refinement_max_size=512, + ) + + assert refiner.calls == [512] + + +def test_service_sorts_final_segs() -> None: + """Final SEGS use the requested shared sorting policy.""" + + detector = RecordingDetector( + { + "face": ( + _detection(0, 0, 1, 1, 0.5), + _detection(2, 0, 4, 2, 0.9), + ) + } + ) + segmenter = RecordingSegmenter( + {"0": [_mask_square(0, 0, 1, 1), _mask_square(2, 0, 4, 2)]} + ) + service = _service(detector, segmenter) + + segs = _prompt( + service, + positive_prompt="face", + size_threshold=1, + sort_order="highest confidence first", + ) + + assert [segment.confidence for segment in segs[1]] == [0.9, 0.5] + + +def _service( + detector: RecordingDetector, + segmenter: RecordingSegmenter, + refiner: RecordingRefiner | None = None, +) -> PromptSEGSWithSAMService: + """Create a service from test doubles.""" + + return PromptSEGSWithSAMService( + runtime=PromptSegsRuntime(detector=detector, segmenter=segmenter), + vitmatte_refiner=refiner, + ) + + +def _prompt( + service: PromptSEGSWithSAMService, + *, + positive_prompt: str = "face", + negative_prompt: str = "", + confidence_threshold: float = 0.3, + size_threshold: int = 1, + bbox_dilation: int = 0, + mask_dilation: int = 0, + detail_method: str = "GuidedFilter", + detail_erode: int = 0, + detail_dilate: int = 0, + black_point: float = 0.0, + white_point: float = 1.0, + refine_mask: bool = False, + mask_refinement_max_size: int = 2048, + execution_device: str = "cpu", + crop_factor: float = 1.0, + sort_order: str = "largest to smallest", +) -> NativeSegs: + """Call the service with common test options.""" + + return service.prompt( + image=make_image_tensor(batch_size=1, height=4, width=4), + sam_model={"sam": "model"}, + grounding_dino_model={"dino": "model"}, + vitmatte_model={"vitmatte": "model"}, + positive_prompt=positive_prompt, + negative_prompt=negative_prompt, + confidence_threshold=confidence_threshold, + size_threshold=size_threshold, + bbox_dilation=bbox_dilation, + mask_dilation=mask_dilation, + detail_method=detail_method, + detail_erode=detail_erode, + detail_dilate=detail_dilate, + black_point=black_point, + white_point=white_point, + refine_mask=refine_mask, + mask_refinement_max_size=mask_refinement_max_size, + execution_device=execution_device, + crop_factor=crop_factor, + sort_order=sort_order, + ) + + +def _detection( + left: int, + top: int, + right: int, + bottom: int, + confidence: float, +) -> TextBoxDetection: + """Create a text detection for tests.""" + + return TextBoxDetection( + bbox=BoundingBox(left, top, right, bottom), + confidence=confidence, + ) + + +def _mask_square(left: int, top: int, right: int, bottom: int) -> torch.Tensor: + """Create a 4x4 mask with one filled square.""" + + mask = torch.zeros((4, 4), dtype=torch.float32) + mask[top:bottom, left:right] = 1.0 + return mask diff --git a/tests/test_regional_detailing_domain.py b/tests/test_regional_detailing_domain.py new file mode 100644 index 0000000..cc8ad7c --- /dev/null +++ b/tests/test_regional_detailing_domain.py @@ -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 + +"""Tests for regional detailing domain pairing.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.regional_detailing import pair_segments_with_conditioning +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment + + +def test_exact_segs_and_conditioning_count_succeeds() -> None: + """Each SEG is paired with the conditioning entry at the same index.""" + + first = _segment("first") + second = _segment("second", CropRegion(4, 0, 8, 4), BoundingBox(5, 1, 7, 3)) + + pairs = pair_segments_with_conditioning( + _segs(first, second), + ConditioningBatch(("positive 1", "positive 2")), + image_height=8, + image_width=8, + ) + + assert [pair.index for pair in pairs] == [0, 1] + assert [pair.segment.label for pair in pairs] == ["first", "second"] + assert [pair.positive for pair in pairs] == ["positive 1", "positive 2"] + + +def test_too_few_conditioning_entries_fail() -> None: + """Region prompts must match SEGS cardinality exactly.""" + + with pytest.raises(ValueError, match="1 conditioning entries for 2 SEGS"): + pair_segments_with_conditioning( + _segs(_segment("first"), _segment("second")), + ConditioningBatch(("positive 1",)), + image_height=8, + image_width=8, + ) + + +def test_too_many_conditioning_entries_fail() -> None: + """Extra region prompts are rejected instead of ignored.""" + + with pytest.raises(ValueError, match="2 conditioning entries for 1 SEGS"): + pair_segments_with_conditioning( + _segs(_segment("first")), + ConditioningBatch(("positive 1", "positive 2")), + image_height=8, + image_width=8, + ) + + +def test_empty_segs_skip_conditioning_validation() -> None: + """Empty SEGS can return unchanged without a regional batch.""" + + pairs = pair_segments_with_conditioning( + ((8, 8), ()), + object(), + image_height=8, + image_width=8, + ) + + assert pairs == () + + +def test_normal_conditioning_batch_fallback_is_not_used() -> None: + """The older batch select fallback must not hide count mismatches.""" + + with pytest.raises(ValueError, match="1 conditioning entries for 2 SEGS"): + pair_segments_with_conditioning( + _segs(_segment("first"), _segment("second")), + ConditioningBatch(("reused by old detailer",)), + image_height=8, + image_width=8, + ) + + +def test_region_positive_must_be_conditioning_batch() -> None: + """Non-empty SEGS require the explicit batch type.""" + + with pytest.raises(TypeError, match="CONDITIONING_BATCH"): + pair_segments_with_conditioning( + _segs(_segment("first")), + "normal conditioning", + image_height=8, + image_width=8, + ) + + +def test_nested_region_positive_batch_fails() -> None: + """Region batch entries must be normal conditioning values.""" + + with pytest.raises(TypeError, match="entry 0"): + pair_segments_with_conditioning( + _segs(_segment("first")), + ConditioningBatch((ConditioningBatch(("nested",)),)), + image_height=8, + image_width=8, + ) + + +def test_segs_header_and_image_dimensions_must_match() -> None: + """The SEGS header is validated against the current image size.""" + + with pytest.raises(ValueError, match="SEGS is 9x8, image is 8x8"): + pair_segments_with_conditioning( + ((9, 8), (_segment("first"),)), + ConditioningBatch(("positive",)), + image_height=8, + image_width=8, + ) + + +def test_segment_crop_must_fit_inside_image() -> None: + """Segments outside the image fail before mask conversion.""" + + with pytest.raises(ValueError, match="crop_region must fit"): + pair_segments_with_conditioning( + _segs(_segment("outside", CropRegion(6, 6, 10, 10))), + ConditioningBatch(("positive",)), + image_height=8, + image_width=8, + ) + + +def _segs(*segments: Segment) -> NativeSegs: + """Return native SEGS for an 8x8 image.""" + + return (8, 8), tuple(segments) + + +def _segment( + label: str, + crop_region: CropRegion | None = None, + bbox: BoundingBox | None = None, +) -> Segment: + """Return one valid test segment.""" + + resolved_region = crop_region or CropRegion(0, 0, 4, 4) + return Segment( + cropped_image=None, + cropped_mask=torch.ones((resolved_region.height, resolved_region.width)), + confidence=1.0, + crop_region=resolved_region, + bbox=bbox or BoundingBox(1, 1, 3, 3), + label=label, + ) diff --git a/tests/test_regional_detailing_masks.py b/tests/test_regional_detailing_masks.py new file mode 100644 index 0000000..731c341 --- /dev/null +++ b/tests/test_regional_detailing_masks.py @@ -0,0 +1,210 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for regional detailing mask conversion.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.domain.regional_detailing import SegmentConditioningPair +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment +from simple_syrup.masking.regional_detailing_masks import ( + build_image_regions, + build_latent_regions, + feather_image_mask, + latent_box_from_mask, + proportional_latent_box, + scale_image_regions, + union_masks, +) + + +def test_full_image_mask_is_placed_from_crop_local_mask() -> None: + """Crop-local SEGS masks are pasted at the segment crop region.""" + + segment = _segment(CropRegion(2, 1, 6, 5), torch.ones((4, 4))) + + (region,) = build_image_regions( + (SegmentConditioningPair(0, segment, "positive"),), + image_height=8, + image_width=8, + ) + + assert region.image_mask.shape == (8, 8) + assert torch.all(region.image_mask[1:5, 2:6] == 1.0) + assert torch.sum(region.image_mask) == 16.0 + + +def test_crop_local_mask_is_resized_to_crop_region_size() -> None: + """Mask placement uses crop-region dimensions, not raw mask dimensions.""" + + segment = _segment(CropRegion(0, 0, 4, 4), torch.ones((2, 2))) + + (region,) = build_image_regions( + (SegmentConditioningPair(0, segment, "positive"),), + image_height=8, + image_width=8, + ) + + assert torch.all(region.image_mask[:4, :4] == 1.0) + assert torch.sum(region.image_mask) == 16.0 + + +def test_latent_mask_resizes_to_bchw_spatial_dimensions() -> None: + """Image masks become latent-space masks using the encoded latent shape.""" + + image_region = build_image_regions( + (SegmentConditioningPair(0, _segment(CropRegion(0, 0, 4, 8)), "positive"),), + image_height=8, + image_width=8, + ) + + (latent_region,) = build_latent_regions( + image_region, + latent_height=4, + latent_width=4, + device=torch.device("cpu"), + dtype=torch.float32, + latent_feather=0, + ) + + assert latent_region.latent_mask.shape == (4, 4) + assert latent_region.latent_box.x == 0 + assert latent_region.latent_box.width == 2 + assert latent_region.latent_box.height == 4 + + +def test_latent_mask_resizes_to_singleton_depth_spatial_dimensions() -> None: + """The mask helper uses only final latent height and width values.""" + + image_region = build_image_regions( + (SegmentConditioningPair(0, _segment(CropRegion(4, 0, 8, 8)), "positive"),), + image_height=8, + image_width=8, + ) + + (latent_region,) = build_latent_regions( + image_region, + latent_height=4, + latent_width=4, + device=torch.device("cpu"), + dtype=torch.float32, + latent_feather=0, + ) + + assert latent_region.latent_mask.shape == (4, 4) + assert latent_region.latent_box.x == 2 + assert latent_region.latent_box.width == 2 + + +def test_proportional_image_to_latent_box_uses_encoded_shape() -> None: + """Image coordinates are scaled against actual latent dimensions.""" + + box = proportional_latent_box( + left=2, + top=4, + right=6, + bottom=8, + image_height=16, + image_width=8, + latent_height=8, + latent_width=4, + ) + + assert (box.x, box.y, box.width, box.height) == (1, 2, 2, 2) + + +def test_scale_image_regions_resizes_masks_and_crop_regions() -> None: + """Scaled image regions preserve mask coverage proportionally.""" + + regions = build_image_regions( + (SegmentConditioningPair(0, _segment(CropRegion(0, 0, 2, 2)), "positive"),), + image_height=4, + image_width=4, + ) + + (scaled,) = scale_image_regions(regions, image_height=8, image_width=8) + + assert scaled.image_mask.shape == (8, 8) + assert scaled.crop_region == CropRegion(0, 0, 4, 4) + assert torch.any(scaled.image_mask[:4, :4] > 0) + + +def test_empty_latent_region_fails() -> None: + """Regions with no latent coverage fail closed.""" + + with pytest.raises(ValueError, match="produced an empty latent region"): + latent_box_from_mask( + torch.zeros((4, 4)), + region_index=3, + label="empty", + ) + + +def test_feather_image_mask_preserves_shape_and_range() -> None: + """Feathering keeps masks usable for image compositing.""" + + mask = torch.zeros((8, 8)) + mask[2:6, 2:6] = 1.0 + + feathered = feather_image_mask(mask, 2) + + assert feathered.shape == mask.shape + assert float(feathered.min()) >= 0.0 + assert float(feathered.max()) <= 1.0 + + +def test_latent_feather_preserves_shape_and_range() -> None: + """Latent feathering keeps region masks clamped.""" + + image_region = build_image_regions( + (SegmentConditioningPair(0, _segment(CropRegion(0, 0, 8, 8)), "positive"),), + image_height=8, + image_width=8, + ) + + (latent_region,) = build_latent_regions( + image_region, + latent_height=4, + latent_width=4, + device=torch.device("cpu"), + dtype=torch.float32, + latent_feather=1, + ) + + assert latent_region.latent_mask.shape == (4, 4) + assert float(latent_region.latent_mask.min()) >= 0.0 + assert float(latent_region.latent_mask.max()) <= 1.0 + + +def test_union_mask_combines_overlaps_without_exceeding_one() -> None: + """Union masks use max-style composition for overlapping SEGS.""" + + first = torch.zeros((4, 4)) + first[:, :3] = 0.75 + second = torch.zeros((4, 4)) + second[:, 1:] = 0.8 + + union = union_masks((first, second)) + + assert union.shape == (4, 4) + assert torch.all(union <= 1.0) + assert torch.allclose(union[:, 1:3], torch.ones((4, 2)) * 0.8) + + +def _segment(region: CropRegion, mask: object | None = None) -> Segment: + """Return one segment using a crop-local mask.""" + + return Segment( + cropped_image=None, + cropped_mask=( + torch.ones((region.height, region.width)) if mask is None else mask + ), + confidence=1.0, + crop_region=region, + bbox=BoundingBox(region.left, region.top, region.right, region.bottom), + label="region", + ) diff --git a/tests/test_regional_multidiffusion_sampling.py b/tests/test_regional_multidiffusion_sampling.py new file mode 100644 index 0000000..766bdf4 --- /dev/null +++ b/tests/test_regional_multidiffusion_sampling.py @@ -0,0 +1,941 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for regional MultiDiffusion sampling runtime.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.domain.regional_detailing import LatentBox, LatentRegion +from simple_syrup.domain.segs import CropRegion +from simple_syrup.runtime import ( + regional_multidiffusion_sampling, + sampling_samplers, + sampling_schedulers, +) +from simple_syrup.runtime.detail_previews import DetailPreviewContext + +comfy_sample = regional_multidiffusion_sampling._comfy_sample() +comfy_utils = regional_multidiffusion_sampling._comfy_utils() +latent_preview = regional_multidiffusion_sampling._latent_preview() + + +def _preview_context() -> DetailPreviewContext: + """Return a minimal regional detail preview context.""" + + return DetailPreviewContext( + image=torch.ones((1, 8, 8, 3), dtype=torch.float32), + work_region=CropRegion(2, 2, 6, 6), + work_mask=torch.ones((8, 8), dtype=torch.float32), + sampled_region=CropRegion(0, 0, 8, 8), + ) + + +def test_sampling_callback_uses_generic_preview_without_detail_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Standalone regional runtime calls keep generic latent previews.""" + + monkeypatch.setattr( + latent_preview, + "prepare_callback", + lambda _model, _steps: "generic callback", + ) + + assert ( + regional_multidiffusion_sampling._sampling_callback(FakeModel(), 4, None) + == "generic callback" + ) + + +def test_sampling_callback_uses_detail_preview_with_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Detailer regional sampling uses the shared detail preview callback.""" + + context = _preview_context() + calls: dict[str, object] = {} + + def fake_prepare_detail_preview_callback( + model: FakeModel, + steps: int, + preview_context: DetailPreviewContext, + ) -> str: + """Record detail preview callback preparation.""" + + calls["model"] = model + calls["steps"] = steps + calls["preview_context"] = preview_context + return "detail callback" + + monkeypatch.setattr( + regional_multidiffusion_sampling, + "prepare_detail_preview_callback", + fake_prepare_detail_preview_callback, + ) + + model = FakeModel() + assert ( + regional_multidiffusion_sampling._sampling_callback(model, 4, context) + == "detail callback" + ) + assert calls == {"model": model, "steps": 4, "preview_context": context} + + +class FakeModel: + """Provide the ModelPatcher methods used by the runtime.""" + + def __init__( + self, + model_options: dict[str, Any] | None = None, + ) -> None: + """Create a fake model patcher.""" + + self.load_device = torch.device("cpu") + self.model_options = {} if model_options is None else model_options + self.calc_wrapper: Any = None + self.model_sampling = object() + + def clone(self) -> FakeModel: + """Return a cloned model with copied options.""" + + return FakeModel(self.model_options.copy()) + + def set_model_sampler_calc_cond_batch_function(self, wrapper: object) -> None: + """Capture the installed calc-cond-batch wrapper.""" + + self.calc_wrapper = wrapper + self.model_options["sampler_calc_cond_batch_function"] = wrapper + + def get_model_object(self, name: str) -> object: + """Return the requested fake model object.""" + + assert name == "model_sampling" + return self.model_sampling + + +class FakeSampler: + """Represent a resolved sampler in tests.""" + + def sample(self, *args: object, **kwargs: object) -> object: + """Provide ComfyUI's sampler protocol.""" + + del args, kwargs + return None + + +def test_clone_model_installs_regional_calc_cond_batch_wrapper() -> None: + """The runtime clones the model and installs a regional wrapper.""" + + model = FakeModel() + + wrapped_model, summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + model, + latent_width=8, + latent_height=4, + latent_ndim=4, + regions=(_region(0, 0, 4, 4, "positive", latent_width=8),), + ) + ) + + assert wrapped_model is not model + assert isinstance( + wrapped_model.calc_wrapper, + regional_multidiffusion_sampling.RegionalMultiDiffusionCalcCondBatch, + ) + assert summary.region_count == 1 + + +def test_clone_model_rejects_non_callable_existing_calc_wrapper() -> None: + """Existing calc-cond-batch metadata must be callable.""" + + model = FakeModel({"sampler_calc_cond_batch_function": object()}) + + with pytest.raises(ValueError, match="Existing sampler_calc_cond_batch_function"): + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + model, + latent_width=8, + latent_height=4, + latent_ndim=4, + regions=(_region(0, 0, 4, 4, "positive", latent_width=8),), + ) + + +def test_existing_calc_wrapper_is_composed_without_recursion() -> None: + """Fallback and regional calls delegate to the previous calc wrapper.""" + + calls: list[dict[str, Any]] = [] + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Return condition-specific constants and record options.""" + + calls.append(args) + assert args["model_options"].get("sampler_calc_cond_batch_function") is existing + x = cast(torch.Tensor, args["input"]) + conds = cast(list[object], args["conds"]) + value = 10.0 if _condition_name(conds[0]) == "global" else 20.0 + return [torch.ones_like(x) * value, torch.ones_like(x) * 2.0] + + model = FakeModel( + { + "sampler_calc_cond_batch_function": existing, + "model_function_wrapper": object(), + } + ) + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + model, + latent_width=4, + latent_height=4, + latent_ndim=4, + regions=(_region(0, 0, 4, 4, "regional"),), + ) + ) + + output = wrapped_model.calc_wrapper( + { + "conds": ["global", "negative"], + "input": torch.zeros((1, 1, 4, 4)), + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert len(calls) == 2 + assert wrapped_model.model_options["model_function_wrapper"] is not None + assert torch.allclose(output[0], torch.ones((1, 1, 4, 4)) * 20.0) + assert torch.allclose(output[1], torch.ones((1, 1, 4, 4)) * 2.0) + + +def test_shape_mismatch_delegates_to_original_calc_path() -> None: + """Unexpected model input spatial shapes are delegated unchanged.""" + + calls = 0 + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Record fallback calls.""" + + nonlocal calls + calls += 1 + x = cast(torch.Tensor, args["input"]) + return [x + 5.0, x + 1.0] + + model = FakeModel({"sampler_calc_cond_batch_function": existing}) + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + model, + latent_width=8, + latent_height=8, + latent_ndim=4, + regions=(_region(0, 0, 4, 4, "regional", latent_width=8, latent_height=8),), + ) + ) + x = torch.zeros((1, 1, 4, 4)) + + output = wrapped_model.calc_wrapper( + { + "conds": ["global", "negative"], + "input": x, + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert calls == 1 + assert torch.allclose(output[0], x + 5.0) + + +def test_region_crops_bchw_final_spatial_axes() -> None: + """Regional calls crop only final height and width axes.""" + + calls: list[torch.Tensor] = [] + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Record regional input crops.""" + + x = cast(torch.Tensor, args["input"]) + calls.append(x) + conds = cast(list[object], args["conds"]) + value = 1.0 if _condition_name(conds[0]) == "global" else 3.0 + return [torch.ones_like(x) * value, torch.zeros_like(x)] + + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + FakeModel({"sampler_calc_cond_batch_function": existing}), + latent_width=8, + latent_height=4, + latent_ndim=4, + regions=(_region(0, 0, 8, 4, "regional", latent_width=8),), + ) + ) + x = torch.arange(32, dtype=torch.float32).reshape((1, 1, 4, 8)) + + output = wrapped_model.calc_wrapper( + { + "conds": ["global", "negative"], + "input": x, + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert calls[1].shape == (1, 1, 4, 8) + assert torch.equal(calls[1], x) + assert torch.allclose(output[0], torch.ones((1, 1, 4, 8)) * 3.0) + + +def test_region_crops_singleton_depth_5d_final_spatial_axes() -> None: + """Anima-style regions crop only final height and width axes.""" + + calls: list[torch.Tensor] = [] + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Record regional input crops.""" + + x = cast(torch.Tensor, args["input"]) + calls.append(x) + conds = cast(list[object], args["conds"]) + value = 1.0 if _condition_name(conds[0]) == "global" else 4.0 + return [torch.ones_like(x) * value, torch.zeros_like(x)] + + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + FakeModel({"sampler_calc_cond_batch_function": existing}), + latent_width=8, + latent_height=4, + latent_ndim=5, + regions=(_region(0, 0, 8, 4, "regional", latent_width=8),), + ) + ) + x = ( + torch.arange(128, dtype=torch.float32) + .reshape((1, 16, 1, 4, 2)) + .repeat(1, 1, 1, 1, 4) + ) + + output = wrapped_model.calc_wrapper( + { + "conds": ["global", "negative"], + "input": x, + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert calls[1].shape == (1, 16, 1, 4, 8) + assert torch.equal(calls[1], x) + assert output[0].shape == x.shape + + +def test_overlapping_regions_normalize_by_accumulated_weight() -> None: + """Overlapping regions are averaged before blending over fallback.""" + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Return different constants for each region positive.""" + + x = cast(torch.Tensor, args["input"]) + cond = _condition_name(cast(list[object], args["conds"])[0]) + values = {"global": 0.0, "first": 2.0, "second": 6.0} + return [torch.ones_like(x) * values[cond], torch.zeros_like(x)] + + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + FakeModel({"sampler_calc_cond_batch_function": existing}), + latent_width=6, + latent_height=4, + latent_ndim=4, + regions=( + _region(0, 0, 4, 4, "first", latent_width=6), + _region(2, 0, 4, 4, "second", latent_width=6), + ), + ) + ) + + output = wrapped_model.calc_wrapper( + { + "conds": ["global", "negative"], + "input": torch.zeros((1, 1, 4, 6)), + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert torch.allclose(output[0][:, :, :, :2], torch.ones((1, 1, 4, 2)) * 2.0) + assert torch.allclose(output[0][:, :, :, 2:4], torch.ones((1, 1, 4, 2)) * 4.0) + assert torch.allclose(output[0][:, :, :, 4:], torch.ones((1, 1, 4, 2)) * 6.0) + + +def test_partial_mask_blends_region_over_fallback() -> None: + """Feathered masks blend region predictions with fallback predictions.""" + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Return fallback or regional constants.""" + + x = cast(torch.Tensor, args["input"]) + cond = _condition_name(cast(list[object], args["conds"])[0]) + value = 10.0 if cond == "global" else 20.0 + return [torch.ones_like(x) * value, torch.zeros_like(x)] + + mask = torch.ones((4, 4)) * 0.25 + region = LatentRegion( + index=0, + label="soft", + latent_box=LatentBox(0, 0, 4, 4), + latent_mask=mask, + positive=_raw_conditioning("regional"), + ) + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + FakeModel({"sampler_calc_cond_batch_function": existing}), + latent_width=4, + latent_height=4, + latent_ndim=4, + regions=(region,), + ) + ) + + output = wrapped_model.calc_wrapper( + { + "conds": ["global", "negative"], + "input": torch.zeros((1, 1, 4, 4)), + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert torch.allclose(output[0], torch.ones((1, 1, 4, 4)) * 12.5) + + +def test_raw_region_conditioning_is_converted_before_calc_cond_batch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Raw CONDITIONING entries are converted before Comfy calc-cond-batch calls.""" + + calls: list[list[list[dict[str, Any]] | None]] = [] + + def calc_cond_batch( + _model: object, + conds: list[list[dict[str, Any]] | None], + x_in: torch.Tensor, + _timestep: torch.Tensor, + _model_options: dict[str, Any], + ) -> list[torch.Tensor]: + """Return constants while asserting sampler-ready conditioning shape.""" + + calls.append(conds) + first_cond = conds[0] + assert first_cond is not None + assert isinstance(first_cond[0], dict) + assert "model_conds" in first_cond[0] + value = 5.0 if "cross_attn" in first_cond[0] else 1.0 + return [torch.ones_like(x_in) * value, torch.zeros_like(x_in)] + + fake_samplers = SimpleNamespace( + calc_cond_batch=calc_cond_batch, + resolve_areas_and_cond_masks_multidim=lambda *_args: None, + calculate_start_end_timesteps=lambda *_args: None, + ) + monkeypatch.setattr( + regional_multidiffusion_sampling, + "_comfy_samplers", + lambda: fake_samplers, + ) + raw_region_positive = [[torch.ones((1, 1, 1)), {}]] + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + FakeModel(), + latent_width=4, + latent_height=4, + latent_ndim=4, + regions=(_region(0, 0, 4, 4, raw_region_positive),), + ) + ) + + output = wrapped_model.calc_wrapper( + { + "conds": [ + [{"model_conds": {}, "uuid": object()}], + [{"model_conds": {}, "uuid": object()}], + ], + "input": torch.zeros((1, 1, 4, 4)), + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert len(calls) == 2 + assert "cross_attn" in cast(list[dict[str, Any]], calls[1][0])[0] + assert torch.allclose(output[0], torch.ones((1, 1, 4, 4)) * 5.0) + + +def test_global_prompt_weight_blends_full_region_with_global_prediction() -> None: + """Covered pixels keep the configured global positive prediction share.""" + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Return fallback or regional constants.""" + + x = cast(torch.Tensor, args["input"]) + cond = _condition_name(cast(list[object], args["conds"])[0]) + value = 10.0 if cond == "global" else 20.0 + return [torch.ones_like(x) * value, torch.zeros_like(x)] + + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + FakeModel({"sampler_calc_cond_batch_function": existing}), + latent_width=4, + latent_height=4, + latent_ndim=4, + regions=(_region(0, 0, 4, 4, "regional"),), + global_prompt_weight=0.25, + ) + ) + + output = wrapped_model.calc_wrapper( + { + "conds": ["global", "negative"], + "input": torch.zeros((1, 1, 4, 4)), + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert torch.allclose(output[0], torch.ones((1, 1, 4, 4)) * 17.5) + + +def test_global_prompt_weight_keeps_partial_mask_coverage() -> None: + """Soft masks scale regional influence before global/regional weighting.""" + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Return fallback or regional constants.""" + + x = cast(torch.Tensor, args["input"]) + cond = _condition_name(cast(list[object], args["conds"])[0]) + value = 10.0 if cond == "global" else 20.0 + return [torch.ones_like(x) * value, torch.zeros_like(x)] + + region = LatentRegion( + index=0, + label="soft", + latent_box=LatentBox(0, 0, 4, 4), + latent_mask=torch.ones((4, 4)) * 0.5, + positive=_raw_conditioning("regional"), + ) + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + FakeModel({"sampler_calc_cond_batch_function": existing}), + latent_width=4, + latent_height=4, + latent_ndim=4, + regions=(region,), + global_prompt_weight=0.25, + ) + ) + + output = wrapped_model.calc_wrapper( + { + "conds": ["global", "negative"], + "input": torch.zeros((1, 1, 4, 4)), + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert torch.allclose(output[0], torch.ones((1, 1, 4, 4)) * 13.75) + + +def test_overlapping_regions_normalize_before_global_prompt_weight_blend() -> None: + """Overlaps average region predictions before applying global weight.""" + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Return different constants for each condition.""" + + x = cast(torch.Tensor, args["input"]) + cond = _condition_name(cast(list[object], args["conds"])[0]) + values = {"global": 10.0, "first": 20.0, "second": 40.0} + return [torch.ones_like(x) * values[cond], torch.zeros_like(x)] + + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + FakeModel({"sampler_calc_cond_batch_function": existing}), + latent_width=4, + latent_height=4, + latent_ndim=4, + regions=( + _region(0, 0, 4, 4, "first"), + _region(0, 0, 4, 4, "second"), + ), + global_prompt_weight=0.25, + ) + ) + + output = wrapped_model.calc_wrapper( + { + "conds": ["global", "negative"], + "input": torch.zeros((1, 1, 4, 4)), + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert torch.allclose(output[0], torch.ones((1, 1, 4, 4)) * 25.0) + + +def test_negative_conditioning_is_reused_for_region_unconditional_path() -> None: + """Regional calls keep the original negative conditioning.""" + + regional_conds: list[list[object]] = [] + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Record all conditioning lists.""" + + x = cast(torch.Tensor, args["input"]) + conds = cast(list[object], args["conds"]) + regional_conds.append(conds) + return [torch.ones_like(x), torch.ones_like(x) * 2.0] + + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + FakeModel({"sampler_calc_cond_batch_function": existing}), + latent_width=4, + latent_height=4, + latent_ndim=4, + regions=(_region(0, 0, 4, 4, "regional"),), + ) + ) + + wrapped_model.calc_wrapper( + { + "conds": ["global", "negative"], + "input": torch.zeros((1, 1, 4, 4)), + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert [_condition_name(item) for item in regional_conds[1]] == [ + "regional", + "negative", + ] + + +def test_cfg_one_none_uncond_still_returns_two_entries() -> None: + """Comfy's CFG=1 optimization keeps the output list shape stable.""" + + def existing(args: dict[str, Any]) -> list[torch.Tensor]: + """Return one tensor per cond slot, even when uncond is None.""" + + x = cast(torch.Tensor, args["input"]) + conds = cast(list[object], args["conds"]) + value = 3.0 if _condition_name(conds[0]) == "regional" else 1.0 + return [torch.ones_like(x) * value, torch.zeros_like(x)] + + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + FakeModel({"sampler_calc_cond_batch_function": existing}), + latent_width=4, + latent_height=4, + latent_ndim=4, + regions=(_region(0, 0, 4, 4, "regional"),), + ) + ) + + output = wrapped_model.calc_wrapper( + { + "conds": ["global", None], + "input": torch.zeros((1, 1, 4, 4)), + "sigma": torch.tensor([1.0]), + "model": wrapped_model, + "model_options": wrapped_model.model_options, + } + ) + + assert len(output) == 2 + assert torch.allclose(output[0], torch.ones((1, 1, 4, 4)) * 3.0) + assert torch.allclose(output[1], torch.zeros((1, 1, 4, 4))) + + +def test_sample_rejects_unipc_before_sampler_resolution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """UniPC sampler names fail before ComfyUI sampler lookup.""" + + def fail_resolve_sampler(_sampler_name: str) -> FakeSampler: + """Fail if sampler resolution is reached.""" + + raise AssertionError("UniPC rejection should happen before sampler resolution") + + monkeypatch.setattr(sampling_samplers, "resolve_sampler", fail_resolve_sampler) + + with pytest.raises(ValueError, match="not compatible with UniPC"): + regional_multidiffusion_sampling.sample_regional_multidiffusion( + model=FakeModel(), + seed=1, + steps=1, + cfg=1.0, + sampler_name="uni_pc", + scheduler="normal", + positive=[], + negative=[], + latent_image={"samples": torch.zeros((1, 4, 4, 4))}, + regions=(_region(0, 0, 4, 4, "regional"),), + denoise=1.0, + global_prompt_weight=0.0, + ) + + +def test_sample_rejects_unsupported_conditioning() -> None: + """Regional and ControlNet conditioning fail closed.""" + + with pytest.raises(ValueError, match="regional conditioning or ControlNet"): + regional_multidiffusion_sampling.sample_regional_multidiffusion( + model=FakeModel(), + seed=1, + steps=1, + cfg=1.0, + sampler_name="euler", + scheduler="normal", + positive=[{"area": (4, 4, 0, 0)}], + negative=[], + latent_image={"samples": torch.zeros((1, 4, 4, 4))}, + regions=(_region(0, 0, 4, 4, "regional"),), + denoise=1.0, + global_prompt_weight=0.0, + ) + + +def test_sample_delegates_to_comfy_sampling_with_cloned_wrapped_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Sampling mirrors KSampler flow while using a wrapped model clone.""" + + calls: dict[str, Any] = {} + model = FakeModel() + sampler = FakeSampler() + latent_samples = torch.zeros((1, 4, 4, 8), dtype=torch.float32) + fixed_noise = torch.ones_like(latent_samples) + fixed_sigmas = torch.tensor([1.0, 0.0], dtype=torch.float32) + sampled = torch.full_like(latent_samples, 0.25) + latent_image: dict[str, Any] = { + "samples": latent_samples, + "downscale_ratio_spacial": 2, + "kept": "value", + } + + monkeypatch.setattr( + sampling_samplers, + "resolve_sampler", + lambda _sampler_name: sampler, + ) + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + lambda **_kwargs: fixed_sigmas, + ) + monkeypatch.setattr( + comfy_sample, + "fix_empty_latent_channels", + lambda _model, samples, _downscale_ratio_spacial: samples, + ) + monkeypatch.setattr( + comfy_sample, + "prepare_noise", + lambda samples, _seed, _batch_inds=None: fixed_noise, + ) + monkeypatch.setattr(latent_preview, "prepare_callback", lambda _model, _steps: None) + monkeypatch.setattr(comfy_utils, "PROGRESS_BAR_ENABLED", False) + + def fake_sample_custom( + received_model: FakeModel, + noise: torch.Tensor, + cfg: float, + received_sampler: FakeSampler, + sigmas: torch.Tensor, + positive: object, + negative: object, + latent_image: torch.Tensor, + noise_mask: torch.Tensor | None, + callback: object, + disable_pbar: bool, + seed: int, + ) -> torch.Tensor: + """Record sample_custom arguments.""" + + calls["sample_custom"] = { + "model": received_model, + "noise": noise, + "cfg": cfg, + "sampler": received_sampler, + "sigmas": sigmas, + "positive": positive, + "negative": negative, + "latent_image": latent_image, + "noise_mask": noise_mask, + "callback": callback, + "disable_pbar": disable_pbar, + "seed": seed, + } + return sampled + + monkeypatch.setattr(comfy_sample, "sample_custom", fake_sample_custom) + + output = regional_multidiffusion_sampling.sample_regional_multidiffusion( + model=model, + seed=123, + steps=2, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + positive=[{"model_conds": {}}], + negative=[{"model_conds": {}}], + latent_image=latent_image, + regions=(_region(0, 0, 4, 4, [{"model_conds": {}}], latent_width=8),), + denoise=1.0, + global_prompt_weight=0.25, + ) + + assert output is not latent_image + assert output["samples"] is sampled + assert output["kept"] == "value" + assert "downscale_ratio_spacial" not in output + assert calls["sample_custom"]["model"] is not model + assert calls["sample_custom"]["model"].calc_wrapper is not None + assert calls["sample_custom"]["sampler"] is sampler + assert calls["sample_custom"]["noise"] is fixed_noise + assert calls["sample_custom"]["disable_pbar"] is True + + +def test_sample_accepts_singleton_depth_5d_latent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Anima-style singleton-depth latents pass runtime validation.""" + + def fake_sample_custom( + _model: object, + _noise: torch.Tensor, + _cfg: float, + _sampler: object, + _sigmas: torch.Tensor, + _positive: object, + _negative: object, + latent_image: torch.Tensor, + **_kwargs: object, + ) -> torch.Tensor: + """Return a deterministic sampled latent for shape validation.""" + + return latent_image + 1.0 + + model = FakeModel() + sampler = FakeSampler() + latent_samples = torch.zeros((1, 16, 1, 4, 8), dtype=torch.float32) + fixed_sigmas = torch.tensor([1.0, 0.0], dtype=torch.float32) + + monkeypatch.setattr( + sampling_samplers, + "resolve_sampler", + lambda _sampler_name: sampler, + ) + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + lambda **_kwargs: fixed_sigmas, + ) + monkeypatch.setattr( + comfy_sample, + "fix_empty_latent_channels", + lambda _model, samples, _downscale_ratio_spacial: samples, + ) + monkeypatch.setattr( + comfy_sample, + "prepare_noise", + lambda samples, _seed, _batch_inds=None: torch.ones_like(samples), + ) + monkeypatch.setattr(latent_preview, "prepare_callback", lambda _model, _steps: None) + monkeypatch.setattr(comfy_utils, "PROGRESS_BAR_ENABLED", True) + monkeypatch.setattr(comfy_sample, "sample_custom", fake_sample_custom) + + output = regional_multidiffusion_sampling.sample_regional_multidiffusion( + model=model, + seed=123, + steps=2, + cfg=7.0, + sampler_name="euler", + scheduler="normal", + positive=[{"model_conds": {}}], + negative=[{"model_conds": {}}], + latent_image={"samples": latent_samples}, + regions=(_region(0, 0, 4, 4, [{"model_conds": {}}], latent_width=8),), + denoise=1.0, + global_prompt_weight=0.25, + ) + + assert torch.equal(output["samples"], latent_samples + 1.0) + + +def _region( + x: int, + y: int, + width: int, + height: int, + positive: object, + *, + latent_width: int = 4, + latent_height: int = 4, +) -> LatentRegion: + """Return one full-weight latent region.""" + + if isinstance(positive, str): + positive = _raw_conditioning(positive) + mask = torch.zeros((latent_height, latent_width), dtype=torch.float32) + mask[y : y + height, x : x + width] = 1.0 + return LatentRegion( + index=0, + label="region", + latent_box=LatentBox(x, y, width, height), + latent_mask=mask, + positive=positive, + ) + + +def _raw_conditioning(name: str) -> list[list[object]]: + """Return a raw Comfy CONDITIONING-like value with a visible test name.""" + + return [[torch.zeros((1, 1, 1), dtype=torch.float32), {"name": name}]] + + +def _condition_name(conditioning: object) -> str: + """Return the test-visible name from raw, processed, or sentinel conditioning.""" + + if isinstance(conditioning, str): + return conditioning + if isinstance(conditioning, list) and conditioning: + first = conditioning[0] + if isinstance(first, dict): + return str(first.get("name", "")) + if ( + isinstance(first, list | tuple) + and len(first) > 1 + and isinstance( + first[1], + dict, + ) + ): + return str(first[1].get("name", "")) + return "" diff --git a/tests/test_registration.py b/tests/test_registration.py new file mode 100644 index 0000000..501e47e --- /dev/null +++ b/tests/test_registration.py @@ -0,0 +1,531 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for SimpleSyrup ComfyUI node registration.""" + +from __future__ import annotations + +import asyncio +import importlib +import subprocess +import sys +from pathlib import Path +from types import ModuleType + +import pytest + + +def test_package_exports_node_mappings() -> None: + """Root package import exposes ComfyUI mapping dictionaries.""" + + package = importlib.import_module("SimpleSyrup") + + assert hasattr(package, "NODE_CLASS_MAPPINGS") + assert hasattr(package, "NODE_DISPLAY_NAME_MAPPINGS") + assert hasattr(package, "comfy_entrypoint") + assert package.WEB_DIRECTORY == "./web/dist" + + +def test_package_imports_from_custom_nodes_parent_path() -> None: + """ComfyUI-style import works without the repository root on sys.path.""" + + project_root = Path(__file__).resolve().parents[1] + custom_nodes_root = project_root.parent + script = ( + "import importlib, pathlib, sys; " + f"project = pathlib.Path({str(project_root)!r}).resolve(); " + "sys.path = [p for p in sys.path " + "if pathlib.Path(p or '.').resolve() != project]; " + f"sys.path.insert(0, {str(custom_nodes_root)!r}); " + "package = importlib.import_module('SimpleSyrup'); " + "assert 'SimpleSyrup.PromptSEGSWithSAM' in package.NODE_CLASS_MAPPINGS; " + "assert 'server' not in sys.modules" + ) + + result = subprocess.run( + [sys.executable, "-c", script], + cwd=custom_nodes_root, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +def test_comfy_import_exposes_stable_internal_package_alias() -> None: + """ComfyUI-style import exposes `simple_syrup` for vendored runtime imports.""" + + project_root = Path(__file__).resolve().parents[1] + custom_nodes_root = project_root.parent + script = ( + "import importlib, pathlib, sys; " + f"project = pathlib.Path({str(project_root)!r}).resolve(); " + "sys.path = [p for p in sys.path " + "if pathlib.Path(p or '.').resolve() != project]; " + f"sys.path.insert(0, {str(custom_nodes_root)!r}); " + "importlib.import_module('SimpleSyrup'); " + "runtime = importlib.import_module(" + "'simple_syrup.third_party.groundingdino_runtime.models'" + "); " + "assert runtime.__name__.endswith('groundingdino_runtime.models')" + ) + + result = subprocess.run( + [sys.executable, "-c", script], + cwd=custom_nodes_root, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +def test_resize_node_is_registered() -> None: + """Resize node id maps to the expected node class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.ResizeImageToTarget"] + + assert registered.__name__ == "ResizeImageToTarget" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.ResizeImageToTarget"] + == "Resize Image to Target" + ) + + +def test_ksampler_extras_node_is_registered() -> None: + """KSampler Extras node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.KSamplerExtras"] + + assert registered.__name__ == "KSamplerExtras" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.KSamplerExtras"] + == "KSampler (Extras)" + ) + + +def test_ksampler_tiled_diffusion_node_is_registered() -> None: + """KSampler tiled diffusion node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.KSamplerTiledDiffusion"] + + assert registered.__name__ == "KSamplerTiledDiffusion" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.KSamplerTiledDiffusion"] + == "KSampler (Tiled Diffusion)" + ) + assert ( + "KSamplerTiledDiffusion" + in importlib.import_module("SimpleSyrup.simple_syrup.nodes").__all__ + ) + assert "SimpleSyrup.KSamplerMixtureOfDiffusers" not in package.NODE_CLASS_MAPPINGS + assert "SimpleSyrup.KSamplerMultiDiffusion" not in package.NODE_CLASS_MAPPINGS + assert ( + "SimpleSyrup.KSamplerMixtureOfDiffusers" + not in package.NODE_DISPLAY_NAME_MAPPINGS + ) + assert ( + "SimpleSyrup.KSamplerMultiDiffusion" not in package.NODE_DISPLAY_NAME_MAPPINGS + ) + + +def test_latent_diagnostics_node_is_registered() -> None: + """Latent Diagnostics node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.LatentDiagnostics"] + + assert registered.__name__ == "LatentDiagnostics" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.LatentDiagnostics"] + == "Latent Diagnostics" + ) + + +def test_prompt_encode_style_node_is_registered() -> None: + """Prompt Encode Style node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.PromptEncodeStyle"] + + assert registered.__name__ == "PromptEncodeStyle" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.PromptEncodeStyle"] + == "Prompt Encode Style" + ) + + +def test_prompt_encode_style_and_normalization_node_is_registered() -> None: + """Prompt Encode Style & Normalization node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS[ + "SimpleSyrup.PromptEncodeStyleAndNormalization" + ] + + assert registered.__name__ == "PromptEncodeStyleAndNormalization" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS[ + "SimpleSyrup.PromptEncodeStyleAndNormalization" + ] + == "Prompt Encode Style & Normalization" + ) + + +def test_prompt_control_encode_style_clean_break_id_is_removed() -> None: + """Old Prompt Control Encode Style node id is not registered.""" + + package = importlib.import_module("SimpleSyrup") + + assert "SimpleSyrup.PromptControlEncodeStyle" not in package.NODE_CLASS_MAPPINGS + assert ( + "SimpleSyrup.PromptControlEncodeStyle" not in package.NODE_DISPLAY_NAME_MAPPINGS + ) + + +def test_prompt_segs_with_sam_node_is_registered() -> None: + """Prompt SEGS w/ SAM node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.PromptSEGSWithSAM"] + + assert registered.__name__ == "PromptSEGSWithSAM" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.PromptSEGSWithSAM"] + == "Prompt SEGS w/ SAM" + ) + assert "SimpleSyrup.PromptSAMMask" not in package.NODE_CLASS_MAPPINGS + assert "SimpleSyrup.PromptSAMMask" not in package.NODE_DISPLAY_NAME_MAPPINGS + + +def test_sam_model_loader_node_is_registered() -> None: + """SAM loader node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.SAMModelLoader"] + + assert registered.__name__ == "SAMModelLoader" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.SAMModelLoader"] + == "SAM Model Loader" + ) + + +def test_scale_factor_node_is_registered() -> None: + """Scale Factor node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.ScaleFactor"] + + assert registered.__name__ == "ScaleFactor" + assert package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.ScaleFactor"] == ( + "Scale Factor" + ) + assert ( + "ScaleFactor" + in importlib.import_module("SimpleSyrup.simple_syrup.nodes").__all__ + ) + + +def test_seed_node_is_registered() -> None: + """Seed node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.Seed"] + + assert registered.__name__ == "Seed" + assert package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.Seed"] == "Seed" + + +def test_grounding_dino_model_loader_node_is_registered() -> None: + """GroundingDINO loader node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.GroundingDINOModelLoader"] + + assert registered.__name__ == "GroundingDINOModelLoader" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.GroundingDINOModelLoader"] + == "GroundingDINO Model Loader" + ) + + +def test_vitmatte_model_loader_node_is_registered() -> None: + """ViTMatte loader node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.ViTMatteModelLoader"] + + assert registered.__name__ == "ViTMatteModelLoader" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.ViTMatteModelLoader"] + == "ViTMatte Model Loader" + ) + + +def test_wd14_tagger_loader_node_is_registered() -> None: + """WD14 tagger loader node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.WD14TaggerLoader"] + + assert registered.__name__ == "WD14TaggerLoader" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.WD14TaggerLoader"] + == "Load WD14 Tagger" + ) + + +def test_load_ultralytics_model_node_is_registered() -> None: + """Load Ultralytics Model node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.LoadUltralyticsModel"] + + assert registered.__name__ == "LoadUltralyticsModel" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.LoadUltralyticsModel"] + == "Load Ultralytics Model" + ) + + +def test_detect_segs_with_ultralytics_node_is_registered() -> None: + """Detect SEGS w/ Ultralytics node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.DetectSEGSWithUltralytics"] + + assert registered.__name__ == "DetectSEGSWithUltralytics" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.DetectSEGSWithUltralytics"] + == "Detect SEGS w/ Ultralytics" + ) + + +def test_detail_segs_by_scale_factor_node_is_registered() -> None: + """Detail SEGS by Scale Factor node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.DetailSEGSByScaleFactor"] + + assert registered.__name__ == "DetailSEGSByScaleFactor" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.DetailSEGSByScaleFactor"] + == "Detail SEGS by Scale Factor" + ) + + +def test_tiled_detail_segs_by_scale_factor_node_is_registered() -> None: + """Tiled Detail SEGS by Scale Factor node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS[ + "SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion" + ] + + assert registered.__name__ == "DetailSEGSByScaleFactorTiledDiffusion" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS[ + "SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion" + ] + == "Detail SEGS by Scale Factor w/ Tiled Diffusion" + ) + assert ( + "DetailSEGSByScaleFactorTiledDiffusion" + in importlib.import_module("SimpleSyrup.simple_syrup.nodes").__all__ + ) + + +def test_detail_segs_as_regions_node_is_registered() -> None: + """Detail SEGS as Regions node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.DetailSEGSAsRegions"] + + assert registered.__name__ == "DetailSEGSAsRegions" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.DetailSEGSAsRegions"] + == "Detail SEGS as Regions" + ) + + +def test_tile_and_tag_segs_node_is_registered() -> None: + """Tile & Tag SEGS node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.TileAndTagSEGS"] + + assert registered.__name__ == "TileAndTagSEGS" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.TileAndTagSEGS"] + == "Tile & Tag SEGS" + ) + + +def test_conditioning_batch_nodes_are_registered() -> None: + """Conditioning batch nodes map to their classes and display names.""" + + package = importlib.import_module("SimpleSyrup") + + assert ( + package.NODE_CLASS_MAPPINGS["SimpleSyrup.ConditioningBatchStart"].__name__ + == "ConditioningBatchStart" + ) + assert ( + package.NODE_CLASS_MAPPINGS["SimpleSyrup.ConditioningBatchAppend"].__name__ + == "ConditioningBatchAppend" + ) + assert ( + package.NODE_CLASS_MAPPINGS["SimpleSyrup.EncodePromptBatch"].__name__ + == "EncodePromptBatch" + ) + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.ConditioningBatchStart"] + == "Conditioning Batch Start" + ) + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.ConditioningBatchAppend"] + == "Conditioning Batch Append" + ) + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.EncodePromptBatch"] + == "Encode Prompt Batch" + ) + + +def test_layerstyle_adapter_node_is_registered() -> None: + """LayerStyle adapter node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.LayerStyleSAMModelsAdapter"] + + assert registered.__name__ == "LayerStyleSAMModelsAdapter" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.LayerStyleSAMModelsAdapter"] + == "LayerStyle SAM Models Adapter" + ) + + +def test_grounded_sam_model_info_node_is_registered() -> None: + """Grounded SAM model info node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.GroundedSAMModelInfo"] + + assert registered.__name__ == "GroundedSAMModelInfo" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.GroundedSAMModelInfo"] + == "Grounded SAM Model Info" + ) + + +def test_simple_load_anima_node_is_registered() -> None: + """Simple Load Anima node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.SimpleLoadAnima"] + + assert registered.__name__ == "SimpleLoadAnima" + assert registered.RETURN_TYPES == ("MODEL", "CLIP", "VAE") + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.SimpleLoadAnima"] + == "Simple Load Anima" + ) + + +def test_simple_load_checkpoint_node_is_registered() -> None: + """Simple Load Checkpoint node id maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.SimpleLoadCheckpoint"] + + assert registered.__name__ == "SimpleLoadCheckpoint" + assert registered.RETURN_TYPES == ("MODEL", "CLIP", "VAE") + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.SimpleLoadCheckpoint"] + == "Simple Load Checkpoint" + ) + + +def test_provenance_latent_nodes_are_registered() -> None: + """Provenance-aware latent nodes map to their classes and display names.""" + + package = importlib.import_module("SimpleSyrup") + nodes_package = importlib.import_module("SimpleSyrup.simple_syrup.nodes") + + simple_vae = package.NODE_CLASS_MAPPINGS["SimpleSyrup.SimpleVAEEncode"] + upscale = package.NODE_CLASS_MAPPINGS["SimpleSyrup.UpscaleLatentFromImage"] + + assert simple_vae.__name__ == "SimpleVAEEncode" + assert upscale.__name__ == "UpscaleLatentFromImage" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.SimpleVAEEncode"] + == "Simple VAE Encode" + ) + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.UpscaleLatentFromImage"] + == "Upscale Latent From Image" + ) + assert "SimpleVAEEncode" in nodes_package.__all__ + assert "UpscaleLatentFromImage" in nodes_package.__all__ + + +def test_registration_import_does_not_require_torchlanc() -> None: + """Importing registration does not eagerly import TorchLanc.""" + + sys.modules.pop("torchlanc", None) + importlib.import_module("SimpleSyrup") + + imported_module: ModuleType | None = sys.modules.get("torchlanc") + assert imported_module is None + + +def test_v3_entrypoint_registers_tile_and_prompt_control_batch_nodes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Comfy v3 entrypoint exposes native v3 nodes without Prompt Control imports.""" + + sys.modules.pop("prompt_control.nodes_lazy", None) + package = importlib.import_module("SimpleSyrup") + nodes_v3 = importlib.import_module("SimpleSyrup.simple_syrup.nodes_v3") + monkeypatch.setattr(nodes_v3, "prompt_control_is_available", lambda: True) + + extension = asyncio.run(package.comfy_entrypoint()) + nodes = asyncio.run(extension.get_node_list()) + + assert [node.__name__ for node in nodes] == [ + "WD14TaggerLoaderV3", + "TileAndTagSEGSV3", + "SimpleLoadCheckpointV3", + "ScaleFactorV3", + "EncodePromptBatchWithPromptControl", + ] + assert "prompt_control.nodes_lazy" not in sys.modules + + +def test_v3_entrypoint_keeps_tile_node_when_prompt_control_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Comfy v3 entrypoint omits only Prompt Control nodes when unavailable.""" + + sys.modules.pop("prompt_control.nodes_lazy", None) + package = importlib.import_module("SimpleSyrup") + nodes_v3 = importlib.import_module("SimpleSyrup.simple_syrup.nodes_v3") + monkeypatch.setattr(nodes_v3, "prompt_control_is_available", lambda: False) + + extension = asyncio.run(package.comfy_entrypoint()) + nodes = asyncio.run(extension.get_node_list()) + + assert [node.__name__ for node in nodes] == [ + "WD14TaggerLoaderV3", + "TileAndTagSEGSV3", + "SimpleLoadCheckpointV3", + "ScaleFactorV3", + ] + assert "prompt_control.nodes_lazy" not in sys.modules diff --git a/tests/test_resize_geometry.py b/tests/test_resize_geometry.py new file mode 100644 index 0000000..e5124a7 --- /dev/null +++ b/tests/test_resize_geometry.py @@ -0,0 +1,215 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for pure resize geometry planning.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.image.resize_geometry import ( + CropPosition, + ResizeMode, + ResizePlan, + ResizeTarget, + apply_divisibility, + build_resize_plan, + calculate_crop_offsets, + calculate_pad_sides, + cover_bounds, + fit_inside, +) + + +def test_fit_inside_preserves_aspect_within_bounds() -> None: + """Fit geometry keeps aspect ratio inside target bounds.""" + + assert fit_inside(800, 400, 300, 300) == (300, 150) + + +def test_cover_bounds_preserves_aspect_while_covering_target() -> None: + """Cover geometry scales until both target dimensions are covered.""" + + assert cover_bounds(800, 400, 300, 300) == (600, 300) + + +def test_stretch_plan_uses_target_dimensions() -> None: + """Stretch mode resizes directly to the target dimensions.""" + + plan = build_resize_plan( + 800, + 400, + ResizeTarget(width=300, height=200, divisible_by=1), + ResizeMode.STRETCH, + CropPosition.CENTER, + ) + + assert plan == ResizePlan( + resize_width=300, + resize_height=200, + output_width=300, + output_height=200, + ) + + +def test_keep_ar_plan_outputs_fitted_dimensions() -> None: + """Keep AR mode outputs the fitted size instead of padding.""" + + plan = build_resize_plan( + 800, + 400, + ResizeTarget(width=300, height=300, divisible_by=1), + ResizeMode.KEEP_AR, + CropPosition.CENTER, + ) + + assert plan == ResizePlan( + resize_width=300, + resize_height=150, + output_width=300, + output_height=150, + ) + + +def test_crop_plan_cover_scales_and_offsets() -> None: + """Crop mode cover-scales and crops according to the chosen anchor.""" + + plan = build_resize_plan( + 800, + 400, + ResizeTarget(width=300, height=300, divisible_by=1), + ResizeMode.CROP, + CropPosition.RIGHT, + ) + + assert plan == ResizePlan( + resize_width=600, + resize_height=300, + output_width=300, + output_height=300, + crop_x=300, + crop_y=0, + ) + + +def test_pad_plan_fit_scales_and_offsets() -> None: + """Pad mode fit-scales and pads according to the chosen anchor.""" + + plan = build_resize_plan( + 800, + 400, + ResizeTarget(width=300, height=300, divisible_by=1), + ResizeMode.PAD, + CropPosition.BOTTOM, + ) + + assert plan == ResizePlan( + resize_width=300, + resize_height=150, + output_width=300, + output_height=300, + pad_left=0, + pad_right=0, + pad_top=150, + pad_bottom=0, + ) + + +@pytest.mark.parametrize( + ("position", "expected"), + [ + (CropPosition.TOP_LEFT, (0, 0)), + (CropPosition.TOP, (50, 0)), + (CropPosition.TOP_RIGHT, (100, 0)), + (CropPosition.LEFT, (0, 25)), + (CropPosition.CENTER, (50, 25)), + (CropPosition.RIGHT, (100, 25)), + (CropPosition.BOTTOM_LEFT, (0, 50)), + (CropPosition.BOTTOM, (50, 50)), + (CropPosition.BOTTOM_RIGHT, (100, 50)), + ], +) +def test_crop_offsets_are_deterministic( + position: CropPosition, + expected: tuple[int, int], +) -> None: + """All crop positions map to deterministic offsets.""" + + assert calculate_crop_offsets(position, 300, 200, 200, 150) == expected + + +@pytest.mark.parametrize( + ("position", "expected"), + [ + (CropPosition.TOP_LEFT, (0, 100, 0, 50)), + (CropPosition.TOP, (50, 50, 0, 50)), + (CropPosition.TOP_RIGHT, (100, 0, 0, 50)), + (CropPosition.LEFT, (0, 100, 25, 25)), + (CropPosition.CENTER, (50, 50, 25, 25)), + (CropPosition.RIGHT, (100, 0, 25, 25)), + (CropPosition.BOTTOM_LEFT, (0, 100, 50, 0)), + (CropPosition.BOTTOM, (50, 50, 50, 0)), + (CropPosition.BOTTOM_RIGHT, (100, 0, 50, 0)), + ], +) +def test_pad_sides_are_deterministic( + position: CropPosition, + expected: tuple[int, int, int, int], +) -> None: + """All pad positions map to deterministic side values.""" + + assert calculate_pad_sides(position, 100, 50) == expected + + +def test_divisibility_steps_dimensions_down() -> None: + """Divisibility floors dimensions to the nearest positive multiple.""" + + assert apply_divisibility(1025, 769, 64) == (1024, 768) + + +def test_divisibility_applies_to_stretch_plan() -> None: + """Stretch plan applies divisibility to final dimensions.""" + + plan = build_resize_plan( + 800, + 400, + ResizeTarget(width=1025, height=769, divisible_by=64), + ResizeMode.STRETCH, + CropPosition.CENTER, + ) + + assert (plan.output_width, plan.output_height) == (1024, 768) + + +def test_invalid_dimensions_raise_value_error() -> None: + """Invalid dimensions produce actionable errors.""" + + with pytest.raises(ValueError, match="source_width must be greater than 0"): + build_resize_plan( + 0, + 400, + ResizeTarget(width=300, height=300, divisible_by=1), + ResizeMode.KEEP_AR, + CropPosition.CENTER, + ) + + +def test_impossible_divisibility_raises_value_error() -> None: + """Dimensions smaller than divisibility cannot produce valid output.""" + + with pytest.raises(ValueError, match="cannot satisfy divisible_by=64"): + apply_divisibility(32, 128, 64) + + +def test_invalid_mode_raises_value_error() -> None: + """Unknown resize modes are rejected explicitly.""" + + with pytest.raises(ValueError, match="Unsupported resize mode"): + build_resize_plan( + 800, + 400, + ResizeTarget(width=300, height=300, divisible_by=1), + "bad mode", + CropPosition.CENTER, + ) diff --git a/tests/test_resize_node.py b/tests/test_resize_node.py new file mode 100644 index 0000000..d39fa9c --- /dev/null +++ b/tests/test_resize_node.py @@ -0,0 +1,126 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Resize Image to Target ComfyUI node declaration.""" + +from __future__ import annotations + +from typing import Any + +import pytest +import torch + +from simple_syrup.nodes.image_resize_to_target import ResizeImageToTarget + + +def test_resize_node_contract_constants() -> None: + """Node constants match the public ComfyUI contract.""" + + assert ResizeImageToTarget.RETURN_TYPES == ("IMAGE", "INT", "INT", "MASK") + assert ResizeImageToTarget.RETURN_NAMES == ("image", "width", "height", "mask") + assert ResizeImageToTarget.FUNCTION == "resize" + assert ResizeImageToTarget.CATEGORY == "SimpleSyrup/Image" + + +def test_resize_node_declares_expected_inputs() -> None: + """Node input declaration includes required controls and optional mask.""" + + input_types: dict[str, dict[str, tuple[Any, ...]]] = ( + ResizeImageToTarget.INPUT_TYPES() + ) + + required = input_types["required"] + optional = input_types["optional"] + + assert set(required) == { + "image", + "width", + "height", + "resize_mode", + "sampling", + "processor", + "divisible_by", + "crop_position", + "pad_color", + "max_batch_size", + "sinc_window", + "precision", + } + assert set(optional) == {"mask"} + assert required["resize_mode"][0] == [ + "Stretch", + "Keep AR", + "Crop (Cover + Crop)", + "Pad (Fit + Pad)", + ] + assert required["sampling"][0] == [ + "nearest-exact", + "bilinear", + "area", + "bicubic", + "lanczos", + ] + assert required["processor"][0] == ["cpu", "gpu"] + + +def test_resize_node_delegates_to_service(monkeypatch: pytest.MonkeyPatch) -> None: + """Node execution delegates directly to the resize service.""" + + calls: list[dict[str, object]] = [] + expected = ( + torch.zeros((1, 2, 2, 3), dtype=torch.float32), + 2, + 2, + torch.zeros((1, 2, 2), dtype=torch.float32), + ) + + class FakeService: + """Service double used to verify node delegation.""" + + def resize( + self, **kwargs: object + ) -> tuple[torch.Tensor, int, int, torch.Tensor]: + """Record delegated keyword arguments.""" + + calls.append(kwargs) + return expected + + monkeypatch.setattr(ResizeImageToTarget, "_service", FakeService()) + image = torch.ones((1, 4, 4, 3), dtype=torch.float32) + mask = torch.ones((1, 4, 4), dtype=torch.float32) + + result = ResizeImageToTarget().resize( + image=image, + width=2, + height=2, + resize_mode="Stretch", + sampling="nearest-exact", + processor="cpu", + divisible_by=1, + crop_position="center", + pad_color="0, 0, 0", + max_batch_size=0, + sinc_window=3, + precision="fp32", + mask=mask, + ) + + assert result == expected + assert calls == [ + { + "image": image, + "width": 2, + "height": 2, + "resize_mode": "Stretch", + "sampling": "nearest-exact", + "processor": "cpu", + "divisible_by": 1, + "crop_position": "center", + "pad_color": "0, 0, 0", + "max_batch_size": 0, + "sinc_window": 3, + "precision": "fp32", + "mask": mask, + } + ] diff --git a/tests/test_resize_resamplers.py b/tests/test_resize_resamplers.py new file mode 100644 index 0000000..29300b1 --- /dev/null +++ b/tests/test_resize_resamplers.py @@ -0,0 +1,206 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for image resampler runtime adapters.""" + +from __future__ import annotations + +import importlib +from types import ModuleType +from typing import Any + +import pytest +import torch + +from simple_syrup.runtime.image_resamplers import ( + NativeComfyResampler, + validate_sampling, +) +from simple_syrup.runtime.torchlanc_resampler import TorchLanczosResampler + + +def test_native_cpu_lanczos_calls_common_upscale() -> None: + """CPU Lanczos uses the native ComfyUI upscale adapter path.""" + + calls: list[tuple[torch.device, int, int, str, str]] = [] + + def fake_common_upscale( + samples: torch.Tensor, + width: int, + height: int, + sampling: str, + crop: str, + ) -> torch.Tensor: + """Record native upscale calls and return resized placeholder data.""" + + calls.append((samples.device, width, height, sampling, crop)) + return torch.zeros( + (samples.shape[0], samples.shape[1], height, width), + dtype=samples.dtype, + device=samples.device, + ) + + samples = torch.ones((1, 3, 4, 6), dtype=torch.float32) + output = NativeComfyResampler( + processor="cpu", + common_upscale=fake_common_upscale, + ).resize(samples, 8, 10, "lanczos") + + assert calls == [(torch.device("cpu"), 8, 10, "lanczos", "disabled")] + assert output.shape == (1, 3, 10, 8) + + +def test_native_cpu_non_lanczos_calls_common_upscale() -> None: + """CPU non-Lanczos samplers use the native ComfyUI PyTorch path.""" + + calls: list[str] = [] + + def fake_common_upscale( + samples: torch.Tensor, + width: int, + height: int, + sampling: str, + crop: str, + ) -> torch.Tensor: + """Record the selected sampler.""" + + calls.append(sampling) + return torch.zeros((samples.shape[0], samples.shape[1], height, width)) + + NativeComfyResampler( + processor="cpu", + common_upscale=fake_common_upscale, + ).resize(torch.ones((1, 3, 4, 6)), 8, 10, "bicubic") + + assert calls == ["bicubic"] + + +def test_native_gpu_uses_device_provider() -> None: + """GPU non-Lanczos path uses ComfyUI's torch-device provider.""" + + provider_calls: list[str] = [] + + def fake_device_provider() -> torch.device: + """Record that the configured device was requested.""" + + provider_calls.append("called") + return torch.device("cpu") + + def fake_common_upscale( + samples: torch.Tensor, + width: int, + height: int, + sampling: str, + crop: str, + ) -> torch.Tensor: + """Return placeholder output for the selected device.""" + + return torch.zeros( + (samples.shape[0], samples.shape[1], height, width), + device=samples.device, + ) + + NativeComfyResampler( + processor="gpu", + common_upscale=fake_common_upscale, + device_provider=fake_device_provider, + ).resize(torch.ones((1, 3, 4, 6)), 8, 10, "area") + + assert provider_calls == ["called"] + + +def test_validate_sampling_rejects_unknown_sampler() -> None: + """Unsupported sampler names are rejected before execution.""" + + with pytest.raises(ValueError, match="Unsupported sampling method"): + validate_sampling("bad") + + +def test_torchlanc_adapter_passes_expected_arguments() -> None: + """TorchLanc adapter maps controls to lanczos_resize arguments.""" + + calls: list[dict[str, Any]] = [] + + def fake_lanczos_resize(samples: torch.Tensor, **kwargs: Any) -> torch.Tensor: + """Record TorchLanc keyword arguments and return the input.""" + + calls.append({"samples": samples, **kwargs}) + return samples + + samples = torch.ones((1, 3, 4, 6), dtype=torch.float32) + output = TorchLanczosResampler( + lanczos_resize=fake_lanczos_resize, + cuda_available=lambda: True, + device_provider=lambda: torch.device("cpu"), + ).resize(samples, 8, 10, sinc_window=4, precision="fp16") + + assert output is samples + assert calls[0]["height"] == 10 + assert calls[0]["width"] == 8 + assert calls[0]["a"] == 4 + assert calls[0]["precision"] == "fp16" + assert calls[0]["clamp"] is True + assert calls[0]["chunk_size"] == 0 + + +def test_torchlanc_adapter_imports_lazily( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """TorchLanc is imported only when the adapter executes.""" + + imported: list[str] = [] + + def fake_import_module(name: str) -> ModuleType: + """Provide a fake TorchLanc module and record import timing.""" + + imported.append(name) + module = ModuleType("torchlanc") + + def fake_lanczos_resize(samples: torch.Tensor, **kwargs: Any) -> torch.Tensor: + """Return samples unchanged for lazy import testing.""" + + return samples + + module.lanczos_resize = fake_lanczos_resize # type: ignore[attr-defined] + return module + + monkeypatch.setattr(importlib, "import_module", fake_import_module) + + adapter = TorchLanczosResampler( + cuda_available=lambda: True, + device_provider=lambda: torch.device("cpu"), + ) + + assert imported == [] + adapter.resize(torch.ones((1, 3, 4, 6)), 8, 10, 3, "fp32") + assert imported == ["torchlanc"] + + +def test_torchlanc_adapter_missing_dependency_is_actionable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing TorchLanc produces a direct installation error.""" + + def fake_import_module(name: str) -> ModuleType: + """Simulate TorchLanc import failure.""" + + raise ModuleNotFoundError(name) + + monkeypatch.setattr(importlib, "import_module", fake_import_module) + adapter = TorchLanczosResampler( + cuda_available=lambda: True, + device_provider=lambda: torch.device("cpu"), + ) + + with pytest.raises(RuntimeError, match="TorchLanc is required"): + adapter.resize(torch.ones((1, 3, 4, 6)), 8, 10, 3, "fp32") + + +def test_torchlanc_adapter_requires_cuda() -> None: + """GPU Lanczos path fails closed when CUDA is unavailable.""" + + adapter = TorchLanczosResampler(cuda_available=lambda: False) + + with pytest.raises(RuntimeError, match="requires a CUDA device"): + adapter.resize(torch.ones((1, 3, 4, 6)), 8, 10, 3, "fp32") diff --git a/tests/test_resize_service.py b/tests/test_resize_service.py new file mode 100644 index 0000000..205820f --- /dev/null +++ b/tests/test_resize_service.py @@ -0,0 +1,218 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the resize image application service.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +import torch + +from simple_syrup.image.resize_service import ( + ResizeImageToTargetService, + parse_pad_color, +) + + +@dataclass +class RecordingProgress: + """Test progress reporter that records update calls.""" + + total: int + updates: list[int] + + def update(self, value: int) -> None: + """Record a progress update.""" + + self.updates.append(value) + + +def test_service_resizes_image_and_returns_shape_metadata() -> None: + """Service returns BHWC image output plus matching width and height.""" + + service = _service_with_progress([]) + image = torch.ones((2, 4, 6, 3), dtype=torch.float32) + + output, width, height, mask = service.resize( + image=image, + width=8, + height=10, + resize_mode="Stretch", + sampling="nearest-exact", + processor="cpu", + divisible_by=1, + crop_position="center", + pad_color="0, 0, 0", + max_batch_size=0, + sinc_window=3, + precision="fp32", + ) + + assert output.shape == (2, 10, 8, 3) + assert (width, height) == (8, 10) + assert mask.shape == (2, 10, 8) + assert torch.count_nonzero(mask) == 0 + + +def test_service_resizes_mask_with_same_geometry() -> None: + """Provided masks are resized and returned with output geometry.""" + + service = _service_with_progress([]) + image = torch.ones((1, 4, 6, 3), dtype=torch.float32) + mask = torch.ones((1, 4, 6), dtype=torch.float32) + + _output, width, height, resized_mask = service.resize( + image=image, + width=8, + height=8, + resize_mode="Pad (Fit + Pad)", + sampling="nearest-exact", + processor="cpu", + divisible_by=1, + crop_position="center", + pad_color="0, 0, 0", + max_batch_size=0, + sinc_window=3, + precision="fp32", + mask=mask, + ) + + assert (width, height) == (8, 8) + assert resized_mask.shape == (1, 8, 8) + assert torch.all(resized_mask[:, 1:6, :] == 1.0) + assert torch.all(resized_mask[:, 0, :] == 0.0) + assert torch.all(resized_mask[:, 6:, :] == 0.0) + + +def test_service_uses_pad_color_for_image_padding() -> None: + """Pad mode fills image padding with parsed RGB values.""" + + service = _service_with_progress([]) + image = torch.zeros((1, 2, 4, 3), dtype=torch.float32) + + output, _width, _height, _mask = service.resize( + image=image, + width=4, + height=4, + resize_mode="Pad (Fit + Pad)", + sampling="nearest-exact", + processor="cpu", + divisible_by=1, + crop_position="center", + pad_color="255, 0, 128", + max_batch_size=0, + sinc_window=3, + precision="fp32", + ) + + expected = torch.tensor([1.0, 0.0, 128.0 / 255.0], dtype=torch.float32) + assert torch.allclose(output[0, 0, 0], expected) + assert torch.allclose(output[0, -1, 0], expected) + + +def test_service_chunking_preserves_batch_order() -> None: + """Chunked execution concatenates output chunks in input order.""" + + progress_updates: list[int] = [] + service = _service_with_progress(progress_updates) + image = torch.stack( + [ + torch.full((2, 2, 3), 0.1, dtype=torch.float32), + torch.full((2, 2, 3), 0.5, dtype=torch.float32), + torch.full((2, 2, 3), 0.9, dtype=torch.float32), + ], + dim=0, + ) + + output, _width, _height, _mask = service.resize( + image=image, + width=2, + height=2, + resize_mode="Stretch", + sampling="nearest-exact", + processor="cpu", + divisible_by=1, + crop_position="center", + pad_color="0, 0, 0", + max_batch_size=1, + sinc_window=3, + precision="fp32", + ) + + assert torch.allclose(output[:, 0, 0, 0], torch.tensor([0.1, 0.5, 0.9])) + assert progress_updates == [1, 1, 1] + + +def test_parse_pad_color_supports_gray_rgb_and_rgba() -> None: + """Pad color parsing adapts to supported channel counts.""" + + assert torch.allclose(parse_pad_color("255, 128, 0", 1), torch.tensor([1.0])) + assert torch.allclose( + parse_pad_color("255, 128, 0", 3), + torch.tensor([1.0, 128.0 / 255.0, 0.0]), + ) + assert torch.allclose( + parse_pad_color("255, 128, 0", 4), + torch.tensor([1.0, 128.0 / 255.0, 0.0, 1.0]), + ) + + +def test_parse_pad_color_rejects_malformed_values() -> None: + """Malformed pad color strings fail before image processing.""" + + with pytest.raises(ValueError, match="exactly three"): + parse_pad_color("0, 0", 3) + with pytest.raises(ValueError, match="not an integer"): + parse_pad_color("0, bad, 0", 3) + + +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"image": torch.ones((1, 4, 6), dtype=torch.float32)}, "shape"), + ({"image": torch.ones((1, 4, 6, 2), dtype=torch.float32)}, "channel"), + ({"processor": "bad"}, "processor"), + ({"sampling": "bad"}, "sampling"), + ({"mask": torch.ones((2, 4, 6), dtype=torch.float32)}, "batch size"), + ], +) +def test_service_rejects_invalid_inputs( + kwargs: dict[str, object], + match: str, +) -> None: + """Invalid service inputs produce clear errors.""" + + service = _service_with_progress([]) + base_kwargs: dict[str, object] = { + "image": torch.ones((1, 4, 6, 3), dtype=torch.float32), + "width": 8, + "height": 10, + "resize_mode": "Stretch", + "sampling": "nearest-exact", + "processor": "cpu", + "divisible_by": 1, + "crop_position": "center", + "pad_color": "0, 0, 0", + "max_batch_size": 0, + "sinc_window": 3, + "precision": "fp32", + "mask": None, + } + base_kwargs.update(kwargs) + + with pytest.raises((TypeError, ValueError), match=match): + service.resize(**base_kwargs) # type: ignore[arg-type] + + +def _service_with_progress(progress_updates: list[int]) -> ResizeImageToTargetService: + """Create a service that uses test progress collection.""" + + def progress_factory(total: int) -> RecordingProgress: + """Return a recording progress reporter.""" + + return RecordingProgress(total=total, updates=progress_updates) + + return ResizeImageToTargetService(progress_factory=progress_factory) diff --git a/tests/test_sam_loader.py b/tests/test_sam_loader.py new file mode 100644 index 0000000..bc454b8 --- /dev/null +++ b/tests/test_sam_loader.py @@ -0,0 +1,243 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for SAM loader runtime service.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path +from types import ModuleType + +import pytest + +from simple_syrup.runtime.loaded_models import LoadedSAMModel +from simple_syrup.runtime.model_downloads import DownloadRequest, DownloadResult +from simple_syrup.runtime.sam_loader import SAMLoaderService, SAMModelCacheKey +from test_helpers import FakeFolderPaths + + +class RecordingDownloader: + """Downloader double that writes requested artifacts.""" + + def __init__(self) -> None: + """Create an empty recording downloader.""" + + self.requests: list[DownloadRequest] = [] + + def download( + self, + request: DownloadRequest, + progress: object | None = None, + ) -> DownloadResult: + """Record and satisfy a download request.""" + + self.requests.append(request) + request.destination_path.parent.mkdir(parents=True, exist_ok=True) + request.destination_path.write_bytes(b"model") + return DownloadResult(request.destination_path, 5, False) + + +def test_sam_loader_downloads_missing_known_artifact( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SAM loader downloads known missing models when enabled.""" + + downloader = RecordingDownloader() + _install_fake_segment_anything(monkeypatch) + + loaded = SAMLoaderService( + downloader=downloader, # type: ignore[arg-type] + folder_paths_module=FakeFolderPaths(tmp_path), + ).load_model("sam_vit_b (375MB)", True) + + assert isinstance(loaded, LoadedSAMModel) + assert loaded.managed_model is not None + assert downloader.requests + + +def test_sam_loader_uses_process_cache_for_identical_resolved_model( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Identical SAM loads reuse the same loaded container and SAM model.""" + + state = _install_fake_segment_anything(monkeypatch) + _create_sam_file(tmp_path, "sam_vit_b_01ec64.pth") + cache: dict[SAMModelCacheKey, LoadedSAMModel] = {} + service = SAMLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache=cache, + ) + + first = service.load_model("sam_vit_b (375MB)", auto_download=True) + second = service.load_model("sam_vit_b (375MB)", auto_download=True) + + assert second is first + assert state.checkpoints == [str(tmp_path / "sams" / "sam_vit_b_01ec64.pth")] + assert len(cache) == 1 + + +def test_sam_loader_cache_separates_model_selections( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Different SAM catalog selections produce separate loaded containers.""" + + state = _install_fake_segment_anything(monkeypatch) + _create_sam_file(tmp_path, "sam_vit_b_01ec64.pth") + _create_sam_file(tmp_path, "sam_vit_l_0b3195.pth") + cache: dict[SAMModelCacheKey, LoadedSAMModel] = {} + service = SAMLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache=cache, + ) + + first = service.load_model("sam_vit_b (375MB)", auto_download=True) + second = service.load_model("sam_vit_l (1.25GB)", auto_download=True) + + assert second is not first + assert state.checkpoints == [ + str(tmp_path / "sams" / "sam_vit_b_01ec64.pth"), + str(tmp_path / "sams" / "sam_vit_l_0b3195.pth"), + ] + assert len(cache) == 2 + + +def test_sam_loader_does_not_cache_failed_registry_load( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed SAM registry construction leaves the cache empty for retry.""" + + state = _install_fake_segment_anything(monkeypatch, fail_once=True) + _create_sam_file(tmp_path, "sam_vit_b_01ec64.pth") + cache: dict[SAMModelCacheKey, LoadedSAMModel] = {} + service = SAMLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache=cache, + ) + + with pytest.raises(RuntimeError, match="SAM failed"): + service.load_model("sam_vit_b (375MB)", auto_download=True) + + loaded = service.load_model("sam_vit_b (375MB)", auto_download=True) + + assert isinstance(loaded, LoadedSAMModel) + assert len(state.checkpoints) == 2 + assert len(cache) == 1 + + +def test_sam_loader_loads_sam_hq_from_owned_runtime( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SAM-HQ catalog entries load from SimpleSyrup's vendored runtime.""" + + state = _install_fake_sam_hq_runtime(monkeypatch) + _create_sam_file(tmp_path, "sam_hq_vit_b.pth") + + loaded = SAMLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + ).load_model("sam_hq_vit_b (379MB)", auto_download=True) + + assert isinstance(loaded, LoadedSAMModel) + assert loaded.model_id == "sam_hq_vit_b" + assert loaded.managed_model is not None + assert state.checkpoints == [str(tmp_path / "sams" / "sam_hq_vit_b.pth")] + + +def test_sam_loader_errors_when_missing_and_download_disabled(tmp_path: Path) -> None: + """SAM loader fails clearly when downloads are disabled.""" + + with pytest.raises(FileNotFoundError, match="auto_download is disabled"): + SAMLoaderService(folder_paths_module=FakeFolderPaths(tmp_path)).load_model( + "sam_vit_b (375MB)", + False, + ) + + +@dataclass +class _FakeSegmentAnythingState: + """Record fake Segment Anything model construction.""" + + checkpoints: list[str] = field(default_factory=list) + fail_once: bool = False + + +def _install_fake_segment_anything( + monkeypatch: pytest.MonkeyPatch, + fail_once: bool = False, +) -> _FakeSegmentAnythingState: + """Install a fake segment_anything registry for loader tests.""" + + state = _FakeSegmentAnythingState(fail_once=fail_once) + + class FakeModel: + """Minimal PyTorch-like model fake.""" + + def __init__(self) -> None: + """Create a model that records device movement.""" + + self.to_calls = 0 + + def to(self, device: object) -> None: + """Record forbidden loader-time device movement.""" + + self.to_calls += 1 + + def eval(self) -> None: + """Accept eval mode.""" + + def build_model(checkpoint: str) -> FakeModel: + """Record checkpoint construction and optionally fail once.""" + + state.checkpoints.append(checkpoint) + if state.fail_once: + state.fail_once = False + raise RuntimeError("SAM failed") + return FakeModel() + + module = ModuleType("segment_anything") + module.sam_model_registry = {"vit_b": build_model, "vit_l": build_model} # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "segment_anything", module) + return state + + +def _install_fake_sam_hq_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> _FakeSegmentAnythingState: + """Install a fake SimpleSyrup SAM-HQ registry for loader tests.""" + + state = _FakeSegmentAnythingState() + + class FakeModel: + """Minimal SAM-HQ model fake.""" + + def to(self, device: object) -> None: + """Accept device movement.""" + + def eval(self) -> None: + """Accept eval mode.""" + + def build_model(checkpoint: str) -> FakeModel: + """Record SAM-HQ checkpoint construction.""" + + state.checkpoints.append(checkpoint) + return FakeModel() + + module = ModuleType("simple_syrup.third_party.sam_hq_runtime.build_sam_hq") + module.sam_model_registry = {"sam_hq_vit_b": build_model} # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module.__name__, module) + return state + + +def _create_sam_file(tmp_path: Path, filename: str) -> None: + """Create one local SAM checkpoint file.""" + + model_dir = tmp_path / "sams" + model_dir.mkdir(parents=True, exist_ok=True) + (model_dir / filename).write_bytes(b"model") diff --git a/tests/test_sam_model_loader_node.py b/tests/test_sam_model_loader_node.py new file mode 100644 index 0000000..c04334d --- /dev/null +++ b/tests/test_sam_model_loader_node.py @@ -0,0 +1,119 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the SAM Model Loader node declaration.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from simple_syrup.nodes.sam_model_loader import SAMModelLoader +from simple_syrup.runtime.model_choices import NO_LOCAL_SAM_MODELS + + +def test_sam_model_loader_contract() -> None: + """SAM loader exposes the conventional SAM_MODEL socket.""" + + assert SAMModelLoader.RETURN_TYPES == ("SAM_MODEL",) + assert SAMModelLoader.RETURN_NAMES == ("sam_model",) + assert SAMModelLoader.FUNCTION == "load_model" + assert SAMModelLoader.CATEGORY == "SimpleSyrup/Masking" + + +def test_sam_model_loader_declares_expected_inputs() -> None: + """SAM loader inputs are deterministic and loader-owned.""" + + input_types: dict[str, dict[str, tuple[Any, ...]]] = SAMModelLoader.INPUT_TYPES() + required = input_types["required"] + + assert set(required) == {"sam_model"} + assert "sam_vit_b (375MB)" in required["sam_model"][0] + + +def test_sam_model_loader_uses_settings_aware_choices() -> None: + """SAM loader dropdown choices come from the model choice service.""" + + class FakeChoices: + """Choice service double for INPUT_TYPES.""" + + def sam_choices(self) -> list[str]: + """Return local-only choices.""" + + return ["sam_vit_h (2.56GB)"] + + original = SAMModelLoader._choices + SAMModelLoader._choices = FakeChoices() # type: ignore[assignment] + try: + required = SAMModelLoader.INPUT_TYPES()["required"] + finally: + SAMModelLoader._choices = original + + assert required["sam_model"][0] == ["sam_vit_h (2.56GB)"] + assert required["sam_model"][1]["default"] == "sam_vit_h (2.56GB)" + + +def test_sam_model_loader_delegates_to_service() -> None: + """Node execution delegates to the loader service.""" + + expected = object() + + class FakeService: + """Service double for node delegation.""" + + def load_model(self, **kwargs: object) -> object: + """Return a fixed model object.""" + + return expected + + node = SAMModelLoader() + original = SAMModelLoader._service + SAMModelLoader._service = FakeService() # type: ignore[assignment] + try: + result = node.load_model( + sam_model="sam_vit_b (375MB)", + ) + finally: + SAMModelLoader._service = original + + assert result == (expected,) + + +def test_sam_model_loader_rejects_sentinel_selection() -> None: + """SAM loader rejects no-local-model sentinel selections.""" + + node = SAMModelLoader() + + with pytest.raises(ValueError, match="No local SAM models are available"): + node.load_model(sam_model=NO_LOCAL_SAM_MODELS) + + +def test_sam_model_loader_always_allows_downloads_for_selected_models() -> None: + """Selected downloadable models are resolved with internal download enabled.""" + + class FakeService: + """Service double that records node download policy.""" + + def __init__(self) -> None: + """Create a recording service double.""" + + self.kwargs: dict[str, object] | None = None + + def load_model(self, **kwargs: object) -> object: + """Record call arguments and return a fixed model object.""" + + self.kwargs = kwargs + return object() + + fake_service = FakeService() + original = SAMModelLoader._service + SAMModelLoader._service = fake_service # type: ignore[assignment] + try: + SAMModelLoader().load_model(sam_model="sam_vit_b (375MB)") + finally: + SAMModelLoader._service = original + + assert fake_service.kwargs is not None + assert fake_service.kwargs["auto_download"] is True diff --git a/tests/test_sam_segmenter.py b/tests/test_sam_segmenter.py new file mode 100644 index 0000000..91b20b0 --- /dev/null +++ b/tests/test_sam_segmenter.py @@ -0,0 +1,380 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for SAM model compatibility adaptation.""" + +from __future__ import annotations + +import sys +from types import ModuleType, SimpleNamespace + +import pytest +import torch + +from simple_syrup.runtime.loaded_models import LoadedSAMModel +from simple_syrup.runtime.model_device_manager import TorchModelDeviceManager +from simple_syrup.runtime.sam_segmenter import SAMModelSegmenter +from test_helpers import make_image_tensor + + +class RecordingWrapper: + """Impact-style SAM wrapper double.""" + + def __init__(self) -> None: + """Create a recording wrapper.""" + + self.prepared = False + self.released = False + self.boxes: list[list[float]] = [] + + def prepare_device(self) -> None: + """Record preparation.""" + + self.prepared = True + + def release_device(self) -> None: + """Record release.""" + + self.released = True + + def predict( + self, + image: object, + points: list[object], + plabs: list[int], + bbox: list[float], + threshold: float, + ) -> list[torch.Tensor]: + """Return one deterministic mask.""" + + self.boxes.append(bbox) + return [torch.ones((2, 2), dtype=torch.float32)] + + +def test_segmenter_accepts_impact_style_sam_wrapper() -> None: + """Objects with `.sam_wrapper` are accepted without importing Impact Pack.""" + + wrapper = RecordingWrapper() + model = SimpleNamespace(sam_wrapper=wrapper) + + result = SAMModelSegmenter().segment_boxes( + model, + make_image_tensor(batch_size=1, height=2, width=2)[0], + torch.tensor([[0.0, 0.0, 2.0, 2.0]], dtype=torch.float32), + threshold=0.3, + execution_device="cpu", + ) + + assert wrapper.prepared is True + assert wrapper.released is True + assert len(result) == 1 + assert torch.equal(result[0], torch.ones((2, 2), dtype=torch.float32)) + + +def test_segmenter_accepts_loaded_sam_model_wrapper() -> None: + """SimpleSyrup loaded SAM containers unwrap before adaptation.""" + + wrapper = RecordingWrapper() + loaded = LoadedSAMModel( + model=SimpleNamespace(sam_wrapper=wrapper), + source="test", + model_id="sam", + ) + + result = SAMModelSegmenter().segment_boxes( + loaded, + make_image_tensor(batch_size=1, height=2, width=2)[0], + torch.tensor([[0.0, 0.0, 2.0, 2.0]], dtype=torch.float32), + threshold=0.3, + execution_device="cpu", + ) + + assert len(result) == 1 + assert result[0].shape == (2, 2) + + +def test_segmenter_returns_empty_tuple_for_empty_boxes() -> None: + """Empty boxes do not require a SAM-compatible object.""" + + result = SAMModelSegmenter().segment_boxes( + object(), + make_image_tensor(batch_size=1, height=2, width=3)[0], + torch.empty((0, 4), dtype=torch.float32), + threshold=0.3, + execution_device="cpu", + ) + + assert result == () + + +def test_segmenter_preserves_one_mask_per_box() -> None: + """Wrapper predictions produce one mask for each requested box.""" + + wrapper = RecordingWrapper() + + result = SAMModelSegmenter().segment_boxes( + SimpleNamespace(sam_wrapper=wrapper), + make_image_tensor(batch_size=1, height=2, width=2)[0], + torch.tensor( + [[0.0, 0.0, 1.0, 1.0], [1.0, 1.0, 2.0, 2.0]], + dtype=torch.float32, + ), + threshold=0.3, + execution_device="cpu", + ) + + assert len(result) == 2 + assert wrapper.boxes == [[0.0, 0.0, 1.0, 1.0], [1.0, 1.0, 2.0, 2.0]] + + +def test_segmenter_combines_candidate_masks_per_box() -> None: + """Multiple masks from one wrapper call are unioned for that box only.""" + + class CandidateWrapper(RecordingWrapper): + """Return two candidates per requested box.""" + + def predict( + self, + image: object, + points: list[object], + plabs: list[int], + bbox: list[float], + threshold: float, + ) -> list[torch.Tensor]: + """Return two complementary masks.""" + + return [ + torch.tensor([[1.0, 0.0], [0.0, 0.0]]), + torch.tensor([[0.0, 0.0], [0.0, 1.0]]), + ] + + result = SAMModelSegmenter().segment_boxes( + SimpleNamespace(sam_wrapper=CandidateWrapper()), + make_image_tensor(batch_size=1, height=2, width=2)[0], + torch.tensor([[0.0, 0.0, 2.0, 2.0]], dtype=torch.float32), + threshold=0.3, + execution_device="cpu", + ) + + assert torch.equal(result[0], torch.tensor([[1.0, 0.0], [0.0, 1.0]])) + + +def test_segmenter_accepts_raw_segment_anything_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Raw SAM models use the segment_anything predictor path.""" + + class FakePredictor: + """Small SamPredictor fake.""" + + def __init__(self, model: object) -> None: + """Create fake predictor.""" + + self.transform = SimpleNamespace( + apply_boxes_torch=lambda boxes, shape: boxes + ) + + def set_image(self, image: object) -> None: + """Accept an image.""" + + def predict_torch(self, **kwargs: object) -> tuple[torch.Tensor, None, None]: + """Return a deterministic mask batch.""" + + return ( + torch.tensor( + [ + [[[1.0, 0.0], [0.0, 0.0]]], + [[[0.0, 0.0], [0.0, 1.0]]], + ], + dtype=torch.float32, + ), + None, + None, + ) + + segment_anything = ModuleType("segment_anything") + segment_anything.SamPredictor = FakePredictor # type: ignore[attr-defined] + comfy = ModuleType("comfy") + model_management = ModuleType("comfy.model_management") + model_management.get_torch_device = lambda: torch.device("cpu") # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "segment_anything", segment_anything) + monkeypatch.setitem(sys.modules, "comfy", comfy) + monkeypatch.setitem(sys.modules, "comfy.model_management", model_management) + + result = SAMModelSegmenter().segment_boxes( + object(), + make_image_tensor(batch_size=1, height=2, width=2)[0], + torch.tensor( + [[0.0, 0.0, 1.0, 1.0], [1.0, 1.0, 2.0, 2.0]], + dtype=torch.float32, + ), + threshold=0.3, + execution_device="cpu", + ) + + assert len(result) == 2 + assert torch.equal(result[0], torch.tensor([[1.0, 0.0], [0.0, 0.0]])) + assert torch.equal(result[1], torch.tensor([[0.0, 0.0], [0.0, 1.0]])) + + +def test_segmenter_uses_manager_for_loaded_raw_sam( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SimpleSyrup-loaded raw SAM models use the shared device context.""" + + class FakeModel: + """SAM model fake that records movement.""" + + def __init__(self) -> None: + """Create the fake.""" + + self.to_calls: list[str] = [] + + def to(self, device: object) -> None: + """Record device movement.""" + + self.to_calls.append(str(device)) + + def eval(self) -> None: + """Accept eval mode.""" + + class FakePredictor: + """Small SamPredictor fake.""" + + def __init__(self, model: object) -> None: + """Create fake predictor.""" + + self.transform = SimpleNamespace( + apply_boxes_torch=lambda boxes, shape: boxes + ) + + def set_image(self, image: object) -> None: + """Accept an image.""" + + def predict_torch(self, **kwargs: object) -> tuple[torch.Tensor, None, None]: + """Return a deterministic mask batch.""" + + return (torch.ones((1, 1, 2, 2), dtype=torch.float32), None, None) + + segment_anything = ModuleType("segment_anything") + segment_anything.SamPredictor = FakePredictor # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "segment_anything", segment_anything) + + model = FakeModel() + loaded = LoadedSAMModel( + model=model, + source="test", + model_id="sam", + managed_model=TorchModelDeviceManager().manage(model, "sam", "test"), + ) + + result = SAMModelSegmenter().segment_boxes( + loaded, + make_image_tensor(batch_size=1, height=2, width=2)[0], + torch.tensor([[0.0, 0.0, 1.0, 1.0]], dtype=torch.float32), + threshold=0.3, + execution_device="cpu", + ) + + assert len(result) == 1 + assert model.to_calls == ["cpu"] + + +def test_segmenter_uses_hq_predictor_for_loaded_sam_hq( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SimpleSyrup-loaded SAM-HQ models use the vendored HQ predictor.""" + + class FakeModel: + """SAM-HQ model fake that records movement.""" + + def __init__(self) -> None: + """Create the fake model.""" + + self.to_calls: list[str] = [] + + def to(self, device: object) -> None: + """Record device movement.""" + + self.to_calls.append(str(device)) + + def eval(self) -> None: + """Accept eval mode.""" + + class FakeHQPredictor: + """Small SamPredictorHQ fake.""" + + used_hq_mode = False + + def __init__(self, model: object, sam_is_hq: bool = False) -> None: + """Record whether the HQ flag was requested.""" + + self.transform = SimpleNamespace( + apply_boxes_torch=lambda boxes, shape: boxes + ) + FakeHQPredictor.used_hq_mode = sam_is_hq + + def set_image(self, image: object) -> None: + """Accept an image.""" + + def predict_torch(self, **kwargs: object) -> tuple[torch.Tensor, None, None]: + """Return a deterministic mask batch.""" + + return (torch.ones((1, 1, 2, 2), dtype=torch.float32), None, None) + + predictor_module = ModuleType("simple_syrup.third_party.sam_hq_runtime.predictor") + predictor_module.SamPredictorHQ = FakeHQPredictor # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, predictor_module.__name__, predictor_module) + + model = FakeModel() + loaded = LoadedSAMModel( + model=model, + source="test", + model_id="sam_hq_vit_b", + managed_model=TorchModelDeviceManager().manage( + model, + "sam_hq_vit_b", + "test", + ), + ) + + result = SAMModelSegmenter().segment_boxes( + loaded, + make_image_tensor(batch_size=1, height=2, width=2)[0], + torch.tensor([[0.0, 0.0, 1.0, 1.0]], dtype=torch.float32), + threshold=0.3, + execution_device="cpu", + ) + + assert len(result) == 1 + assert FakeHQPredictor.used_hq_mode is True + assert model.to_calls == ["cpu"] + + +def test_segmenter_rejects_invalid_mask_shape() -> None: + """Invalid wrapper mask shapes fail clearly.""" + + class InvalidWrapper(RecordingWrapper): + """Return an invalid mask shape.""" + + def predict( + self, + image: object, + points: list[object], + plabs: list[int], + bbox: list[float], + threshold: float, + ) -> list[torch.Tensor]: + """Return a one-dimensional mask.""" + + return [torch.ones((2,), dtype=torch.float32)] + + with pytest.raises(ValueError, match="invalid mask shape"): + SAMModelSegmenter().segment_boxes( + SimpleNamespace(sam_wrapper=InvalidWrapper()), + make_image_tensor(batch_size=1, height=2, width=2)[0], + torch.tensor([[0.0, 0.0, 2.0, 2.0]], dtype=torch.float32), + threshold=0.3, + execution_device="cpu", + ) diff --git a/tests/test_sampling_samplers.py b/tests/test_sampling_samplers.py new file mode 100644 index 0000000..08343d6 --- /dev/null +++ b/tests/test_sampling_samplers.py @@ -0,0 +1,103 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for KSampler Extras sampler runtime helpers.""" + +from __future__ import annotations + +import comfy.samplers +import pytest + +from simple_syrup.runtime.a1111_sampling import sample_euler_ancestral_a1111 +from simple_syrup.runtime.sampling_samplers import ( + available_samplers, + resolve_sampler, +) + + +class FakeKSampler: + """Capture a sampler function while exposing ComfyUI's sampler protocol.""" + + def __init__(self, sampler_function: object) -> None: + """Create a fake KSampler wrapper.""" + + self.sampler_function = sampler_function + + def sample(self, *args: object, **kwargs: object) -> object: + """Provide the sampler protocol expected by runtime code.""" + + del args, kwargs + return None + + +def test_available_samplers_includes_core_and_extras() -> None: + """Sampler options combine ComfyUI core names with SimpleSyrup extras.""" + + samplers = available_samplers() + + for sampler in comfy.samplers.KSampler.SAMPLERS: + assert sampler in samplers + assert samplers[: len(comfy.samplers.KSampler.SAMPLERS)] == tuple( + comfy.samplers.KSampler.SAMPLERS + ) + assert samplers[-1] == "euler_a_a1111" + + +def test_available_samplers_deduplicates_local_extra_when_globally_patched( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The local A1111 sampler is shown once if another extension patched ComfyUI.""" + + monkeypatch.setattr( + comfy.samplers.KSampler, + "SAMPLERS", + tuple(comfy.samplers.KSampler.SAMPLERS) + ("euler_a_a1111",), + ) + + samplers = available_samplers() + + assert samplers.count("euler_a_a1111") == 1 + assert "euler_a_a1111" in samplers + + +def test_available_samplers_includes_lcm() -> None: + """LCM is exposed because it is already a core ComfyUI sampler.""" + + assert "lcm" in available_samplers() + + +def test_resolve_sampler_returns_comfy_sampler_object() -> None: + """Valid sampler names resolve to executable ComfyUI sampler objects.""" + + sampler = resolve_sampler("lcm") + + assert callable(sampler.sample) + + +def test_resolve_euler_a_a1111_returns_local_sampler_object( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The A1111 sampler resolves locally without ComfyUI sampler_object lookup.""" + + def fail_sampler_object(sampler_name: str) -> object: + """Fail if local sampler resolution delegates to ComfyUI by name.""" + + del sampler_name + raise AssertionError("euler_a_a1111 should not call sampler_object") + + monkeypatch.setattr(comfy.samplers, "KSAMPLER", FakeKSampler) + monkeypatch.setattr(comfy.samplers, "sampler_object", fail_sampler_object) + + sampler = resolve_sampler("euler_a_a1111") + + assert callable(sampler.sample) + assert isinstance(sampler, FakeKSampler) + assert sampler.sampler_function is sample_euler_ancestral_a1111 + + +def test_resolve_sampler_rejects_unknown_sampler() -> None: + """Unsupported sampler names fail before sampling begins.""" + + with pytest.raises(ValueError, match="Unsupported sampler 'not-real'"): + resolve_sampler("not-real") diff --git a/tests/test_sampling_schedulers.py b/tests/test_sampling_schedulers.py new file mode 100644 index 0000000..37d8d4b --- /dev/null +++ b/tests/test_sampling_schedulers.py @@ -0,0 +1,802 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for KSampler Extras scheduler runtime helpers.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from typing import Any + +import comfy.samplers +import pytest +import torch + +from simple_syrup.runtime import sampling_schedulers +from simple_syrup.runtime.sampling_schedulers import ( + available_schedulers, + calculate_sigmas, +) + + +class FakeModel: + """Provide the model-sampling object expected by scheduler helpers.""" + + def __init__(self) -> None: + """Create a fake model with a stable model_sampling object.""" + + self.model_sampling = object() + + def get_model_object(self, name: str) -> object: + """Return the requested fake model object.""" + + assert name == "model_sampling" + return self.model_sampling + + +class FakeDiscreteModelSampling: + """Provide k-diffusion-style discrete sigma conversion for tests.""" + + def __init__(self, sigmas: Sequence[float]) -> None: + """Create a fake discrete model sampling object.""" + + self.sigmas = torch.tensor(sigmas, dtype=torch.float32) + self.log_sigmas = self.sigmas.log() + + def sigma(self, timestep: torch.Tensor) -> torch.Tensor: + """Convert fractional timesteps to sigmas with log-linear interpolation.""" + + timestep = torch.clamp( + timestep.float().to(self.log_sigmas.device), + min=0, + max=(len(self.sigmas) - 1), + ) + low_index = timestep.floor().long() + high_index = timestep.ceil().long() + weight = timestep.frac() + log_sigma = (1 - weight) * self.log_sigmas[ + low_index + ] + weight * self.log_sigmas[high_index] + return log_sigma.exp().to(timestep.device) + + +class FakeDiscreteModel: + """Provide a discrete model_sampling object for automatic_a1111 tests.""" + + def __init__(self) -> None: + """Create a fake model with k-diffusion-style sigmas.""" + + self.model_sampling = FakeDiscreteModelSampling((0.1, 0.3, 1.0)) + + def get_model_object(self, name: str) -> FakeDiscreteModelSampling: + """Return the requested fake model sampling object.""" + + assert name == "model_sampling" + return self.model_sampling + + +def assert_sigmas_close(actual: torch.Tensor, expected: list[float]) -> None: + """Assert that calculated sigmas match fixed reference values.""" + + assert torch.allclose( + actual.cpu(), + torch.tensor(expected, dtype=torch.float32), + atol=1e-5, + rtol=1e-5, + ) + + +def reference_extra_sigmas( + scheduler_name: str, + sampler_name: str, + steps: int, + denoise: float, +) -> torch.Tensor: + """Calculate expected extra scheduler sigmas with KSampler semantics.""" + + if denoise <= 0.0: + return torch.FloatTensor([]) + + schedule_steps = steps if denoise > 0.9999 else int(steps / denoise) + if sampler_name in comfy.samplers.KSampler.DISCARD_PENULTIMATE_SIGMA_SAMPLERS: + schedule_steps += 1 + + sigmas = reference_full_extra_schedule(scheduler_name, schedule_steps) + if sampler_name in comfy.samplers.KSampler.DISCARD_PENULTIMATE_SIGMA_SAMPLERS: + sigmas = torch.cat([sigmas[:-2], sigmas[-1:]]) + + if denoise <= 0.9999: + sigmas = sigmas[-(steps + 1) :] + return sigmas + + +def reference_full_extra_schedule(scheduler_name: str, steps: int) -> torch.Tensor: + """Calculate expected full AYS/GITS formula output for tests.""" + + if scheduler_name == "AYS SD1": + return reference_ays_schedule("SD1", steps) + if scheduler_name == "AYS SDXL": + return reference_ays_schedule("SDXL", steps) + if scheduler_name == "GITS": + return reference_gits_schedule(steps) + if scheduler_name == "automatic_a1111": + return reference_automatic_a1111_schedule(FakeDiscreteModel(), steps) + raise ValueError(f"Unsupported reference scheduler '{scheduler_name}'.") + + +def reference_ays_schedule(model_type: str, steps: int) -> torch.Tensor: + """Calculate full AYS schedule with Comfy Extras formula semantics.""" + + sigmas = list(sampling_schedulers.AYS_NOISE_LEVELS[model_type]) + if (steps + 1) != len(sigmas): + sigmas = reference_loglinear_interpolate(sigmas, steps + 1) + sigmas[-1] = 0.0 + return torch.FloatTensor(sigmas) + + +def reference_gits_schedule(steps: int) -> torch.Tensor: + """Calculate full GITS schedule for the default coefficient.""" + + if steps <= 20: + sigmas = list(sampling_schedulers.GITS_DEFAULT_NOISE_LEVELS[steps - 2]) + else: + sigmas = reference_loglinear_interpolate( + sampling_schedulers.GITS_DEFAULT_NOISE_LEVELS[-1], + steps + 1, + ) + sigmas[-1] = 0.0 + return torch.FloatTensor(sigmas) + + +def reference_automatic_a1111_schedule( + model: FakeDiscreteModel, + steps: int, +) -> torch.Tensor: + """Calculate k-diffusion DiscreteSchedule.get_sigmas-style output.""" + + model_sampling = model.model_sampling + timesteps = torch.linspace( + len(model_sampling.sigmas) - 1, + 0, + steps, + device=model_sampling.sigmas.device, + ) + sigmas = model_sampling.sigma(timesteps) + return torch.cat([sigmas, sigmas.new_zeros([1])]).cpu() + + +def reference_loglinear_interpolate( + sigmas: Sequence[float], + num_steps: int, +) -> list[float]: + """Interpolate reference sigma values in log space.""" + + reversed_logs = [math.log(value) for value in reversed(sigmas)] + source_max = len(reversed_logs) - 1 + target_max = num_steps - 1 + interpolated: list[float] = [] + + for target_index in range(num_steps): + source_position = target_index * source_max / target_max + left_index = math.floor(source_position) + right_index = min(left_index + 1, source_max) + fraction = source_position - left_index + left_value = reversed_logs[left_index] + right_value = reversed_logs[right_index] + interpolated.append( + math.exp(left_value + (right_value - left_value) * fraction) + ) + + return list(reversed(interpolated)) + + +def test_available_schedulers_includes_core_and_extras() -> None: + """Scheduler options combine ComfyUI core names with SimpleSyrup extras.""" + + schedulers = available_schedulers() + + for scheduler in comfy.samplers.KSampler.SCHEDULERS: + assert scheduler in schedulers + assert schedulers[-5:] == ( + "AYS SD1", + "AYS SDXL", + "GITS", + "beta57", + "automatic_a1111", + ) + + +def test_available_schedulers_deduplicates_beta57_when_globally_patched( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The local beta57 option is shown once if another extension patched ComfyUI.""" + + monkeypatch.setattr( + comfy.samplers.KSampler, + "SCHEDULERS", + tuple(comfy.samplers.KSampler.SCHEDULERS) + ("beta57",), + ) + + schedulers = available_schedulers() + + assert schedulers.count("beta57") == 1 + assert "beta57" in schedulers + + +def test_available_schedulers_deduplicates_automatic_a1111_when_globally_patched( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The local A1111 scheduler is shown once if another extension patched ComfyUI.""" + + monkeypatch.setattr( + comfy.samplers.KSampler, + "SCHEDULERS", + tuple(comfy.samplers.KSampler.SCHEDULERS) + ("automatic_a1111",), + ) + + schedulers = available_schedulers() + + assert schedulers.count("automatic_a1111") == 1 + assert "automatic_a1111" in schedulers + + +def test_available_schedulers_excludes_svd_scheduler() -> None: + """Unsupported SVD scheduling is excluded from the available scheduler list.""" + + assert "AYS SVD" not in available_schedulers() + + +def test_unknown_scheduler_is_rejected() -> None: + """Unsupported scheduler names fail before sampling begins.""" + + with pytest.raises(ValueError, match="Unsupported scheduler 'not-real'"): + calculate_sigmas(FakeModel(), "not-real", "euler", 20, 1.0) + + +def test_core_scheduler_delegates_to_comfy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Core schedulers use ComfyUI's installed sigma implementation.""" + + calls: list[dict[str, Any]] = [] + + def fake_calculate_sigmas( + model_sampling: object, + scheduler_name: str, + steps: int, + ) -> torch.Tensor: + """Record delegation arguments and return deterministic sigmas.""" + + calls.append( + { + "model_sampling": model_sampling, + "scheduler_name": scheduler_name, + "steps": steps, + } + ) + return torch.arange(steps + 1, dtype=torch.float32) + + monkeypatch.setattr( + comfy.samplers, + "calculate_sigmas", + fake_calculate_sigmas, + ) + + model = FakeModel() + sigmas = calculate_sigmas(model, "normal", "euler", 4, 1.0) + + assert calls == [ + { + "model_sampling": model.model_sampling, + "scheduler_name": "normal", + "steps": 4, + } + ] + assert torch.equal(sigmas, torch.tensor([0, 1, 2, 3, 4], dtype=torch.float32)) + + +def test_core_scheduler_zero_denoise_returns_empty_tensor() -> None: + """Denoise zero skips sigma generation.""" + + sigmas = calculate_sigmas(FakeModel(), "normal", "euler", 20, 0.0) + + assert sigmas.shape == (0,) + + +def test_core_scheduler_partial_denoise_truncates_sigmas( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Partial denoise follows built-in KSampler truncation semantics.""" + + def fake_calculate_sigmas( + model_sampling: object, + scheduler_name: str, + steps: int, + ) -> torch.Tensor: + """Return a predictable sequence for denoise truncation.""" + + return torch.arange(steps + 1, dtype=torch.float32) + + monkeypatch.setattr( + comfy.samplers, + "calculate_sigmas", + fake_calculate_sigmas, + ) + + sigmas = calculate_sigmas(FakeModel(), "normal", "euler", 4, 0.5) + + assert torch.equal(sigmas, torch.tensor([4, 5, 6, 7, 8], dtype=torch.float32)) + + +def test_beta57_full_denoise_uses_res4lyf_preset_parameters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """beta57 calls ComfyUI's beta scheduler with RES4LYF's vendored preset.""" + + calls: list[dict[str, object]] = [] + + def fake_beta_scheduler( + model_sampling: object, + steps: int, + alpha: float, + beta: float, + ) -> torch.Tensor: + """Record beta scheduler arguments and return deterministic sigmas.""" + + calls.append( + { + "model_sampling": model_sampling, + "steps": steps, + "alpha": alpha, + "beta": beta, + } + ) + return torch.arange(steps + 1, dtype=torch.float32) + + monkeypatch.setattr(comfy.samplers, "beta_scheduler", fake_beta_scheduler) + + model = FakeModel() + sigmas = calculate_sigmas(model, "beta57", "euler", 4, 1.0) + + assert calls == [ + { + "model_sampling": model.model_sampling, + "steps": 4, + "alpha": sampling_schedulers.BETA57_ALPHA, + "beta": sampling_schedulers.BETA57_BETA, + } + ] + assert torch.equal(sigmas, torch.tensor([0, 1, 2, 3, 4], dtype=torch.float32)) + + +def test_beta57_partial_denoise_uses_expanded_schedule_then_truncates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """beta57 follows KSampler partial-denoise expansion and tail truncation.""" + + calls: list[int] = [] + + def fake_beta_scheduler( + model_sampling: object, + steps: int, + alpha: float, + beta: float, + ) -> torch.Tensor: + """Return a predictable sequence for denoise truncation.""" + + del model_sampling, alpha, beta + calls.append(steps) + return torch.arange(steps + 1, dtype=torch.float32) + + monkeypatch.setattr(comfy.samplers, "beta_scheduler", fake_beta_scheduler) + + sigmas = calculate_sigmas(FakeModel(), "beta57", "euler", 4, 0.5) + + assert calls == [8] + assert torch.equal(sigmas, torch.tensor([4, 5, 6, 7, 8], dtype=torch.float32)) + + +def test_beta57_zero_denoise_returns_empty_tensor_without_scheduler_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Denoise zero skips beta57 sigma generation.""" + + def fake_beta_scheduler( + model_sampling: object, + steps: int, + alpha: float, + beta: float, + ) -> torch.Tensor: + """Fail if zero denoise reaches ComfyUI scheduler calculation.""" + + del model_sampling, steps, alpha, beta + raise AssertionError("beta_scheduler should not be called") + + monkeypatch.setattr(comfy.samplers, "beta_scheduler", fake_beta_scheduler) + + sigmas = calculate_sigmas(FakeModel(), "beta57", "euler", 20, 0.0) + + assert sigmas.shape == (0,) + + +def test_beta57_discards_penultimate_sigma_for_matching_samplers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """beta57 keeps ComfyUI KSampler cleanup for DPM-style sampler schedules.""" + + calls: list[int] = [] + + def fake_beta_scheduler( + model_sampling: object, + steps: int, + alpha: float, + beta: float, + ) -> torch.Tensor: + """Return sigmas long enough to verify penultimate cleanup.""" + + del model_sampling, alpha, beta + calls.append(steps) + return torch.arange(steps + 1, dtype=torch.float32) + + monkeypatch.setattr(comfy.samplers, "beta_scheduler", fake_beta_scheduler) + + sigmas = calculate_sigmas(FakeModel(), "beta57", "dpm_2", 4, 1.0) + + assert calls == [5] + assert torch.equal(sigmas, torch.tensor([0, 1, 2, 3, 5], dtype=torch.float32)) + + +def test_beta57_uses_local_path_when_comfy_scheduler_list_is_patched( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """beta57 is resolved locally even if another extension patched ComfyUI.""" + + def fail_core_calculate_sigmas( + model_sampling: object, + scheduler_name: str, + steps: int, + ) -> torch.Tensor: + """Fail if beta57 delegates to ComfyUI's global scheduler lookup.""" + + del model_sampling, scheduler_name, steps + raise AssertionError("beta57 should not use core calculate_sigmas") + + def fake_beta_scheduler( + model_sampling: object, + steps: int, + alpha: float, + beta: float, + ) -> torch.Tensor: + """Return deterministic local beta57 sigmas.""" + + del model_sampling, alpha, beta + return torch.arange(steps + 1, dtype=torch.float32) + + monkeypatch.setattr( + comfy.samplers.KSampler, + "SCHEDULERS", + tuple(comfy.samplers.KSampler.SCHEDULERS) + ("beta57",), + ) + monkeypatch.setattr( + comfy.samplers, + "calculate_sigmas", + fail_core_calculate_sigmas, + ) + monkeypatch.setattr(comfy.samplers, "beta_scheduler", fake_beta_scheduler) + + sigmas = calculate_sigmas(FakeModel(), "beta57", "euler", 4, 1.0) + + assert torch.equal(sigmas, torch.tensor([0, 1, 2, 3, 4], dtype=torch.float32)) + + +def test_automatic_a1111_full_denoise_matches_discrete_schedule() -> None: + """automatic_a1111 reproduces k-diffusion DiscreteSchedule.get_sigmas.""" + + model = FakeDiscreteModel() + sigmas = calculate_sigmas(model, "automatic_a1111", "euler", 4, 1.0) + expected = reference_automatic_a1111_schedule(model, 4) + + assert torch.allclose(sigmas, expected, atol=1e-6, rtol=1e-6) + + +def test_automatic_a1111_partial_denoise_expands_then_truncates() -> None: + """automatic_a1111 follows KSampler partial-denoise expansion semantics.""" + + model = FakeDiscreteModel() + sigmas = calculate_sigmas(model, "automatic_a1111", "euler", 4, 0.5) + expected = reference_automatic_a1111_schedule(model, 8)[-(4 + 1) :] + + assert torch.allclose(sigmas, expected, atol=1e-6, rtol=1e-6) + + +def test_automatic_a1111_zero_denoise_returns_empty_tensor() -> None: + """Denoise zero skips automatic_a1111 sigma generation.""" + + sigmas = calculate_sigmas(FakeDiscreteModel(), "automatic_a1111", "euler", 20, 0.0) + + assert sigmas.shape == (0,) + + +def test_automatic_a1111_discards_penultimate_sigma_for_matching_samplers() -> None: + """automatic_a1111 keeps ComfyUI KSampler cleanup for DPM-style schedules.""" + + model = FakeDiscreteModel() + sigmas = calculate_sigmas(model, "automatic_a1111", "dpm_2", 4, 1.0) + expected = reference_automatic_a1111_schedule(model, 5) + expected = torch.cat([expected[:-2], expected[-1:]]) + + assert torch.allclose(sigmas, expected, atol=1e-6, rtol=1e-6) + + +def test_automatic_a1111_uses_local_path_when_comfy_scheduler_list_is_patched( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """automatic_a1111 is resolved locally even if ComfyUI is globally patched.""" + + def fail_core_calculate_sigmas( + model_sampling: object, + scheduler_name: str, + steps: int, + ) -> torch.Tensor: + """Fail if automatic_a1111 delegates to ComfyUI's scheduler lookup.""" + + del model_sampling, scheduler_name, steps + raise AssertionError("automatic_a1111 should not use core calculate_sigmas") + + monkeypatch.setattr( + comfy.samplers.KSampler, + "SCHEDULERS", + tuple(comfy.samplers.KSampler.SCHEDULERS) + ("automatic_a1111",), + ) + monkeypatch.setattr( + comfy.samplers, + "calculate_sigmas", + fail_core_calculate_sigmas, + ) + + sigmas = calculate_sigmas(FakeDiscreteModel(), "automatic_a1111", "euler", 4, 1.0) + expected = reference_automatic_a1111_schedule(FakeDiscreteModel(), 4) + + assert torch.allclose(sigmas, expected, atol=1e-6, rtol=1e-6) + + +def test_automatic_a1111_rejects_unsupported_model_sampling_object() -> None: + """automatic_a1111 fails clearly for model sampling objects without sigmas.""" + + with pytest.raises(ValueError, match="automatic_a1111 requires"): + calculate_sigmas(FakeModel(), "automatic_a1111", "euler", 4, 1.0) + + +def test_ays_sd1_full_schedule_matches_reference_values() -> None: + """AYS SD1 full schedules match fixed Comfy Extras reference values.""" + + model = FakeModel() + + assert_sigmas_close( + calculate_sigmas(model, "AYS SD1", "euler", 10, 1.0), + [ + 14.61464119, + 6.474576, + 3.86367464, + 2.69461513, + 1.88419211, + 1.39438045, + 0.96425837, + 0.65236861, + 0.39774564, + 0.15152326, + 0.0, + ], + ) + assert_sigmas_close( + calculate_sigmas(model, "AYS SD1", "euler", 20, 1.0), + [ + 14.61464119, + 9.72746658, + 6.474576, + 5.00156546, + 3.86367464, + 3.22662616, + 2.69461513, + 2.25325823, + 1.88419211, + 1.62088883, + 1.39438045, + 1.15954435, + 0.96425837, + 0.79312789, + 0.65236861, + 0.50938863, + 0.39774564, + 0.24549484, + 0.15152326, + 0.06647934, + 0.0, + ], + ) + + +def test_ays_sdxl_full_schedule_matches_reference_values() -> None: + """AYS SDXL full schedules match fixed Comfy Extras reference values.""" + + model = FakeModel() + + assert_sigmas_close( + calculate_sigmas(model, "AYS SDXL", "euler", 10, 1.0), + [ + 14.61464119, + 6.31844854, + 3.76817894, + 2.18114805, + 1.34052444, + 0.86207211, + 0.55506933, + 0.37985408, + 0.23323642, + 0.11141882, + 0.0, + ], + ) + assert_sigmas_close( + calculate_sigmas(model, "AYS SDXL", "euler", 20, 1.0), + [ + 14.61464119, + 9.60946751, + 6.31844854, + 4.87945127, + 3.76817894, + 2.86687231, + 2.18114805, + 1.70993638, + 1.34052444, + 1.07500172, + 0.86207211, + 0.69174403, + 0.55506933, + 0.45917898, + 0.37985408, + 0.29765046, + 0.23323642, + 0.16120461, + 0.11141882, + 0.05700676, + 0.0, + ], + ) + + +def test_gits_full_schedule_matches_reference_values() -> None: + """GITS full schedules match fixed reference values at the default coefficient.""" + + model = FakeModel() + + assert_sigmas_close( + calculate_sigmas(model, "GITS", "euler", 10, 1.0), + [ + 14.61464119, + 5.85520077, + 2.84484982, + 1.67050016, + 1.08895338, + 0.74807048, + 0.50118381, + 0.32104823, + 0.19894916, + 0.09824532, + 0.0, + ], + ) + assert_sigmas_close( + calculate_sigmas(model, "GITS", "euler", 20, 1.0), + [ + 14.61464119, + 7.49001646, + 4.65472794, + 3.07277966, + 2.19988537, + 1.61558151, + 1.24153244, + 0.95350921, + 0.74807048, + 0.59516323, + 0.50118381, + 0.41087446, + 0.34370604, + 0.29807833, + 0.25053367, + 0.22545385, + 0.19894916, + 0.17026083, + 0.13792117, + 0.09824532, + 0.0, + ], + ) + assert_sigmas_close( + calculate_sigmas(model, "GITS", "euler", 30, 1.0), + [ + 14.61464119, + 9.35946941, + 6.39175272, + 4.65472794, + 3.52900577, + 2.74887061, + 2.19988537, + 1.7906853, + 1.47980762, + 1.24153244, + 1.04120433, + 0.87942225, + 0.74807048, + 0.64230049, + 0.56202602, + 0.50118381, + 0.43900734, + 0.38714039, + 0.34370604, + 0.31257147, + 0.28130382, + 0.25053367, + 0.23352164, + 0.21624818, + 0.19894916, + 0.17933175, + 0.1587158, + 0.13792117, + 0.11000647, + 0.0655402, + 0.0, + ], + ) + + +@pytest.mark.parametrize("scheduler_name", ["AYS SD1", "AYS SDXL", "GITS"]) +@pytest.mark.parametrize("sampler_name", ["euler", "dpm_2"]) +@pytest.mark.parametrize("steps", [5, 10, 20, 30]) +@pytest.mark.parametrize("denoise", [0.25, 0.5, 0.8, 1.0]) +def test_extra_schedulers_follow_ksampler_denoise_semantics( + scheduler_name: str, + sampler_name: str, + steps: int, + denoise: float, +) -> None: + """Extra schedulers use KSampler partial-denoise and sigma cleanup rules.""" + + actual = calculate_sigmas( + FakeModel(), + scheduler_name, + sampler_name, + steps, + denoise, + ) + expected = reference_extra_sigmas(scheduler_name, sampler_name, steps, denoise) + + assert actual.shape == expected.shape + assert torch.allclose(actual, expected, atol=1e-5, rtol=1e-5) + if denoise < 1.0: + assert actual.shape == (steps + 1,) + + +def test_reported_ays_sd1_partial_denoise_regression() -> None: + """AYS SD1 with partial denoise returns a KSampler-length sigma schedule.""" + + actual = calculate_sigmas(FakeModel(), "AYS SD1", "euler", 5, 0.25) + expected = reference_extra_sigmas("AYS SD1", "euler", 5, 0.25) + + assert actual.shape == (6,) + assert torch.allclose(actual, expected, atol=1e-5, rtol=1e-5) + + +def test_gits_scope_uses_default_coefficient() -> None: + """The simple node contract exposes GITS at its default coefficient only.""" + + assert sampling_schedulers.GITS_DEFAULT_COEFF == 1.20 + + +def test_beta57_scope_uses_res4lyf_preset_parameters() -> None: + """The beta57 scheduler contract exposes RES4LYF's fixed beta preset.""" + + assert sampling_schedulers.BETA57_ALPHA == 0.5 + assert sampling_schedulers.BETA57_BETA == 0.7 diff --git a/tests/test_scale_factor_node.py b/tests/test_scale_factor_node.py new file mode 100644 index 0000000..d35aebb --- /dev/null +++ b/tests/test_scale_factor_node.py @@ -0,0 +1,56 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Scale Factor node contract.""" + +from __future__ import annotations + +from simple_syrup.nodes import tooltips +from simple_syrup.nodes.scale_factor import ScaleFactor, scale_factor_options + + +def test_scale_factor_options_returns_fresh_bounded_metadata() -> None: + """Scale-factor widget options expose bounded FLOAT metadata.""" + + first_options = scale_factor_options() + second_options = scale_factor_options(default=1.0) + + assert first_options is not second_options + assert first_options["default"] == 1.5 + assert first_options["min"] == 1.0 + assert first_options["max"] == 5.0 + assert first_options["step"] == 0.1 + assert isinstance(first_options["tooltip"], str) + assert first_options["tooltip"] + assert second_options["default"] == 1.0 + + +def test_scale_factor_node_contract() -> None: + """Scale Factor exposes a bounded FLOAT primitive contract.""" + + inputs = ScaleFactor.INPUT_TYPES() + value_type, value_options = inputs["required"]["value"] + + assert ScaleFactor.RETURN_TYPES == ("FLOAT",) + assert ScaleFactor.RETURN_NAMES == ("scale_factor",) + assert ScaleFactor.FUNCTION == "get_value" + assert ScaleFactor.CATEGORY == "SimpleSyrup/Primitives" + assert ScaleFactor.DESCRIPTION == "Provides a bounded multiplier for scaling." + assert len(ScaleFactor.OUTPUT_TOOLTIPS) == 1 + assert ScaleFactor.OUTPUT_TOOLTIPS[0] == ( + "Multiplier used to scale a connected target." + ) + assert list(inputs["required"]) == ["value"] + assert value_type == "FLOAT" + assert value_options["default"] == 1.5 + assert value_options["min"] == 1.0 + assert value_options["max"] == 5.0 + assert value_options["step"] == 0.1 + assert value_options["tooltip"] == tooltips.SCALE_FACTOR_VALUE + + +def test_scale_factor_node_returns_float_value() -> None: + """Scale Factor returns the provided value as a FLOAT output.""" + + assert ScaleFactor().get_value("2.5") == (2.5,) diff --git a/tests/test_scale_factor_v3_node.py b/tests/test_scale_factor_v3_node.py new file mode 100644 index 0000000..0810e4e --- /dev/null +++ b/tests/test_scale_factor_v3_node.py @@ -0,0 +1,37 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Scale Factor Comfy v3 wrapper.""" + +from __future__ import annotations + +from simple_syrup.nodes import tooltips +from simple_syrup.nodes_v3.scale_factor import ScaleFactorV3 + + +def test_scale_factor_v3_schema() -> None: + """Scale Factor v3 schema exposes the same bounded float contract.""" + + schema = ScaleFactorV3.define_schema() + + assert schema.node_id == "SimpleSyrup.ScaleFactor" + assert schema.display_name == "Scale Factor" + assert schema.category == "SimpleSyrup/Primitives" + assert schema.description == "Provides a bounded multiplier for scaling." + assert [input_item.id for input_item in schema.inputs] == ["value"] + assert schema.inputs[0].io_type == "FLOAT" + assert schema.inputs[0].default == 1.5 + assert schema.inputs[0].min == 1.0 + assert schema.inputs[0].max == 5.0 + assert schema.inputs[0].step == 0.1 + assert schema.inputs[0].tooltip == tooltips.SCALE_FACTOR_VALUE + assert [output.id for output in schema.outputs] == ["scale_factor"] + assert schema.outputs[0].io_type == "FLOAT" + assert schema.outputs[0].tooltip == "Multiplier used to scale a connected target." + + +def test_scale_factor_v3_execute_delegates_to_legacy_node() -> None: + """Scale Factor v3 execution returns the legacy node output shape.""" + + assert ScaleFactorV3.execute(2.5) == (2.5,) diff --git a/tests/test_seed_node.py b/tests/test_seed_node.py new file mode 100644 index 0000000..947ad81 --- /dev/null +++ b/tests/test_seed_node.py @@ -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 + +"""Tests for the reusable Seed ComfyUI node.""" + +from __future__ import annotations + +from typing import Any + +from simple_syrup.nodes.seed import Seed + + +def test_seed_node_contract_constants() -> None: + """Seed node exposes the intended ComfyUI output contract.""" + + assert Seed.RETURN_TYPES == ("INT",) + assert Seed.RETURN_NAMES == ("seed",) + assert Seed.FUNCTION == "execute" + assert Seed.CATEGORY == "SimpleSyrup/Utilities" + + +def test_seed_node_declares_native_seed_input() -> None: + """Seed input uses ComfyUI's native seed widget metadata.""" + + input_types: dict[str, dict[str, tuple[Any, ...]]] = Seed.INPUT_TYPES() + + required = input_types["required"] + assert tuple(required) == ("seed",) + + seed_input = required["seed"] + assert seed_input[0] == "INT" + + metadata = seed_input[1] + assert metadata["default"] == 0 + assert metadata["min"] == 0 + assert metadata["max"] == 0xFFFFFFFFFFFFFFFF + assert metadata["control_after_generate"] is True + + +def test_seed_node_returns_seed_unchanged() -> None: + """Execution returns the selected seed value without modification.""" + + assert Seed().execute(123) == (123,) diff --git a/tests/test_segs_domain.py b/tests/test_segs_domain.py new file mode 100644 index 0000000..1f8447b --- /dev/null +++ b/tests/test_segs_domain.py @@ -0,0 +1,293 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for SimpleSyrup SEGS domain compatibility.""" + +from __future__ import annotations + +from collections import namedtuple + +import pytest + +from simple_syrup.domain.segs import ( + SORT_ORDER_OPTIONS, + BoundingBox, + CropRegion, + Segment, + coerce_segment, + coerce_segs, + coerce_segs_group, + sort_segs, + to_impact_compatible_segs, + to_impact_compatible_segs_group, +) + +ImpactSegment = namedtuple( + "ImpactSegment", + [ + "cropped_image", + "cropped_mask", + "confidence", + "crop_region", + "bbox", + "label", + "control_net_wrapper", + ], +) + + +def test_native_segment_round_trips_to_impact_shape() -> None: + """Native SEGS emits the tuple/list shape expected by Impact-style nodes.""" + + segment = Segment( + cropped_image=None, + cropped_mask="mask", + confidence=0.75, + crop_region=CropRegion(1, 2, 11, 12), + bbox=BoundingBox(3, 4, 9, 10), + label="face", + ) + + header, segments = to_impact_compatible_segs(((100, 200), (segment,))) + + assert header == (100, 200) + assert segments == [segment] + assert segments[0].crop_region[0] == 1 + assert segments[0].bbox.right == 9 + + +def test_impact_namedtuple_segment_is_accepted() -> None: + """Attribute-compatible Impact segments coerce to native segments.""" + + impact_segment = ImpactSegment( + None, + "mask", + 0.5, + [0, 0, 16, 24], + [4, 6, 10, 18], + "person", + None, + ) + + header, segments = coerce_segs(((24, 16), [impact_segment])) + + assert header == (24, 16) + assert segments[0] == Segment( + cropped_image=None, + cropped_mask="mask", + confidence=0.5, + crop_region=CropRegion(0, 0, 16, 24), + bbox=BoundingBox(4, 6, 10, 18), + label="person", + ) + + +def test_missing_segment_attributes_are_rejected() -> None: + """Invalid segment objects fail with a useful message.""" + + with pytest.raises(ValueError, match="missing required attribute"): + coerce_segment(object()) + + +def test_invalid_crop_coordinates_are_rejected() -> None: + """Crop coordinates must define a positive region.""" + + impact_segment = ImpactSegment( + None, "mask", 1.0, [5, 0, 5, 10], [0, 0, 1, 1], "x", None + ) + + with pytest.raises(ValueError, match="crop_region"): + coerce_segment(impact_segment) + + +def test_empty_segment_list_is_valid() -> None: + """SEGS can represent no detections.""" + + assert coerce_segs(((32, 64), [])) == ((32, 64), ()) + + +def test_single_segs_coerces_to_one_item_group() -> None: + """A normal SEGS payload is accepted as a one-item group.""" + + segment = _segment("face", CropRegion(0, 0, 2, 2), 0.9) + + assert coerce_segs_group(((16, 16), [segment])) == (((16, 16), (segment,)),) + + +def test_segs_list_coerces_to_group_in_order() -> None: + """A Comfy list of SEGS becomes an ordered native group.""" + + first = ((16, 16), [_segment("first", CropRegion(0, 0, 2, 2), 0.9)]) + second = ((16, 16), [_segment("second", CropRegion(2, 2, 4, 4), 0.8)]) + + group = coerce_segs_group([first, second]) + + assert [segs[1][0].label for segs in group] == ["first", "second"] + + +def test_empty_segs_group_is_rejected() -> None: + """A SEGS group must contain at least one per-image payload.""" + + with pytest.raises(ValueError, match="one or more SEGS payloads"): + coerce_segs_group([]) + + +def test_malformed_segs_group_item_is_rejected_with_index() -> None: + """Invalid group items fail with an actionable item index.""" + + with pytest.raises(ValueError, match="SEGS group item 1 is invalid"): + coerce_segs_group([("not-a-header", [])]) + + +def test_impact_segs_group_conversion_returns_list_outputs() -> None: + """SEGS groups convert to Comfy list output values.""" + + first = ((16, 16), (_segment("first", CropRegion(0, 0, 2, 2), 0.9),)) + second = ((16, 16), (_segment("second", CropRegion(2, 2, 4, 4), 0.8),)) + + output = to_impact_compatible_segs_group((first, second)) + + assert isinstance(output, list) + assert [segments[0].label for _header, segments in output] == ["first", "second"] + assert all(isinstance(segments, list) for _header, segments in output) + + +def test_sort_order_options_are_plain_english_and_ordered() -> None: + """SEGS sort options match the detector node combo contract.""" + + assert SORT_ORDER_OPTIONS == ( + "largest to smallest", + "smallest to largest", + "widest to thinnest", + "thinnest to widest", + "tallest to shortest", + "shortest to tallest", + "top to bottom", + "bottom to top", + "left to right", + "right to left", + "highest confidence first", + "lowest confidence first", + ) + + +@pytest.mark.parametrize( + ("sort_order", "expected_labels"), + [ + ("largest to smallest", ["large", "wide", "small"]), + ("smallest to largest", ["small", "wide", "large"]), + ("widest to thinnest", ["wide", "large", "small"]), + ("thinnest to widest", ["small", "large", "wide"]), + ("tallest to shortest", ["large", "small", "wide"]), + ("shortest to tallest", ["wide", "small", "large"]), + ("top to bottom", ["large", "wide", "small"]), + ("bottom to top", ["small", "wide", "large"]), + ("left to right", ["large", "small", "wide"]), + ("right to left", ["wide", "small", "large"]), + ("highest confidence first", ["small", "wide", "large"]), + ("lowest confidence first", ["large", "wide", "small"]), + ], +) +def test_sort_segs_orders_by_selected_policy( + sort_order: str, + expected_labels: list[str], +) -> None: + """Each supported sort policy orders by the documented primary key.""" + + segs = ( + (16, 16), + ( + _segment("large", CropRegion(0, 0, 4, 6), 0.2), + _segment("small", CropRegion(3, 8, 5, 11), 0.9), + _segment("wide", CropRegion(8, 2, 14, 4), 0.5), + ), + ) + + _header, sorted_segments = sort_segs(segs, sort_order) + + assert [segment.label for segment in sorted_segments] == expected_labels + + +def test_sort_segs_uses_confidence_as_first_non_confidence_tie_breaker() -> None: + """Equal primary values prefer the stronger detection first.""" + + lower = _segment("lower", CropRegion(0, 0, 4, 4), 0.4) + higher = _segment("higher", CropRegion(8, 8, 12, 12), 0.9) + + _header, sorted_segments = sort_segs( + ((16, 16), (lower, higher)), "largest to smallest" + ) + + assert [segment.label for segment in sorted_segments] == ["higher", "lower"] + + +def test_sort_segs_confidence_sort_uses_top_left_tie_breakers() -> None: + """Equal confidence values fall back to top, then left.""" + + later = _segment("later", CropRegion(2, 4, 4, 6), 0.8) + earlier = _segment("earlier", CropRegion(6, 1, 8, 3), 0.8) + leftmost = _segment("leftmost", CropRegion(1, 1, 3, 3), 0.8) + + _header, sorted_segments = sort_segs( + ((16, 16), (later, earlier, leftmost)), + "highest confidence first", + ) + + assert [segment.label for segment in sorted_segments] == [ + "leftmost", + "earlier", + "later", + ] + + +def test_sort_segs_preserves_original_index_after_all_ties() -> None: + """Fully tied segments keep their original detection order.""" + + first = _segment("first", CropRegion(0, 0, 4, 4), 0.8) + second = _segment("second", CropRegion(0, 0, 4, 4), 0.8) + + _header, sorted_segments = sort_segs( + ((16, 16), (first, second)), + "highest confidence first", + ) + + assert [segment.label for segment in sorted_segments] == ["first", "second"] + + +def test_sort_segs_rejects_unknown_sort_order() -> None: + """Unknown sort options fail instead of silently changing behavior.""" + + with pytest.raises(ValueError, match="Unknown SEGS sort order"): + sort_segs(((16, 16), ()), "detection order") + + +def test_sort_segs_does_not_mutate_input() -> None: + """Sorting returns a new SEGS tuple without mutating the source order.""" + + first = _segment("first", CropRegion(0, 0, 2, 2), 0.1) + second = _segment("second", CropRegion(0, 0, 4, 4), 0.2) + segs = ((16, 16), (first, second)) + + _header, sorted_segments = sort_segs(segs, "largest to smallest") + + assert [segment.label for segment in sorted_segments] == ["second", "first"] + assert [segment.label for segment in segs[1]] == ["first", "second"] + + +def _segment(label: str, crop_region: CropRegion, confidence: float) -> Segment: + """Create a segment for domain sorting tests.""" + + return Segment( + cropped_image=None, + cropped_mask="mask", + confidence=confidence, + crop_region=crop_region, + bbox=BoundingBox( + crop_region.left, + crop_region.top, + crop_region.right, + crop_region.bottom, + ), + label=label, + ) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..03990d8 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,102 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for SimpleSyrup backend settings persistence.""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import ModuleType + +import pytest + +from simple_syrup.runtime.settings import ( + SimpleSyrupSettings, + SimpleSyrupSettingsError, + SimpleSyrupSettingsRepository, +) + + +def test_default_settings_show_downloadable_models() -> None: + """Default settings favor low-friction model discovery.""" + + assert SimpleSyrupSettings().show_downloadable_models is True + + +def test_missing_settings_file_returns_defaults(tmp_path: Path) -> None: + """Missing persisted settings are treated as default settings.""" + + repository = SimpleSyrupSettingsRepository(tmp_path / "settings.json") + + assert repository.load() == SimpleSyrupSettings() + + +def test_valid_settings_file_is_loaded(tmp_path: Path) -> None: + """A valid settings file controls the backend setting.""" + + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"show_downloadable_models": False}), + encoding="utf-8", + ) + + assert SimpleSyrupSettingsRepository(path).load() == SimpleSyrupSettings( + show_downloadable_models=False + ) + + +def test_invalid_json_file_returns_defaults(tmp_path: Path) -> None: + """Malformed JSON fails closed to defaults without deleting user data.""" + + path = tmp_path / "settings.json" + path.write_text("{not-json", encoding="utf-8") + + assert SimpleSyrupSettingsRepository(path).load() == SimpleSyrupSettings() + assert path.read_text(encoding="utf-8") == "{not-json" + + +def test_invalid_schema_returns_defaults(tmp_path: Path) -> None: + """Malformed settings schema fails closed to defaults.""" + + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"show_downloadable_models": "false"}), + encoding="utf-8", + ) + + assert SimpleSyrupSettingsRepository(path).load() == SimpleSyrupSettings() + + +def test_saving_settings_writes_validated_schema(tmp_path: Path) -> None: + """Saving settings writes only the known schema.""" + + path = tmp_path / "nested" / "settings.json" + repository = SimpleSyrupSettingsRepository(path) + + repository.save(SimpleSyrupSettings(show_downloadable_models=False)) + + assert json.loads(path.read_text(encoding="utf-8")) == { + "show_downloadable_models": False + } + + +def test_settings_path_uses_comfy_user_directory(tmp_path: Path) -> None: + """Settings path resolution uses Comfy's user directory API.""" + + folder_paths = ModuleType("folder_paths") + folder_paths.get_user_directory = lambda: str(tmp_path) # type: ignore[attr-defined] + + repository = SimpleSyrupSettingsRepository(folder_paths_module=folder_paths) + + assert repository.settings_path() == ( + tmp_path / "default" / "SimpleSyrup" / "settings.json" + ) + + +def test_payload_validation_rejects_non_boolean_value() -> None: + """Schema validation rejects non-boolean setting values.""" + + with pytest.raises(SimpleSyrupSettingsError, match="show_downloadable_models"): + SimpleSyrupSettings.from_payload({"show_downloadable_models": 1}) diff --git a/tests/test_settings_routes.py b/tests/test_settings_routes.py new file mode 100644 index 0000000..914957d --- /dev/null +++ b/tests/test_settings_routes.py @@ -0,0 +1,188 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for SimpleSyrup backend settings routes.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable +from pathlib import Path +from typing import cast + +import pytest +from aiohttp import web + +import simple_syrup.runtime.settings_routes as settings_routes +from simple_syrup.runtime.settings import ( + SimpleSyrupSettings, + SimpleSyrupSettingsRepository, +) +from simple_syrup.runtime.settings_routes import ( + SETTINGS_ROUTE, + Handler, + PromptServerProtocol, + register_settings_routes, +) + + +class FakeRoutes: + """Route table double that records decorated handlers.""" + + def __init__(self) -> None: + """Create an empty fake route table.""" + + self.get_handlers: dict[str, Handler] = {} + self.post_handlers: dict[str, Handler] = {} + + def get(self, path: str) -> Callable[[Handler], Handler]: + """Record a GET route handler.""" + + def decorator(handler: Handler) -> Handler: + self.get_handlers[path] = handler + return handler + + return decorator + + def post(self, path: str) -> Callable[[Handler], Handler]: + """Record a POST route handler.""" + + def decorator(handler: Handler) -> Handler: + self.post_handlers[path] = handler + return handler + + return decorator + + +class FakePromptServer: + """PromptServer double exposing a route table.""" + + def __init__(self) -> None: + """Create a fake PromptServer.""" + + self.routes = FakeRoutes() + + +class FakeRequest: + """Request double with injectable JSON body behavior.""" + + def __init__(self, payload: object | BaseException) -> None: + """Create a request that returns or raises from `json()`.""" + + self._payload = payload + + async def json(self) -> object: + """Return the configured JSON payload.""" + + if isinstance(self._payload, BaseException): + raise self._payload + return self._payload + + +def test_route_registration_records_get_and_post_handlers(tmp_path: Path) -> None: + """Settings routes register with Comfy's PromptServer routes.""" + + prompt_server = FakePromptServer() + repository = SimpleSyrupSettingsRepository(tmp_path / "settings.json") + + assert register_fake_routes(repository, prompt_server) is True + + assert SETTINGS_ROUTE in prompt_server.routes.get_handlers + assert SETTINGS_ROUTE in prompt_server.routes.post_handlers + + +def test_route_registration_is_import_safe_without_prompt_server( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing Comfy PromptServer does not break package import.""" + + monkeypatch.setattr(settings_routes, "_prompt_server_instance", lambda: None) + + assert register_settings_routes(prompt_server=None) is False + + +def test_get_settings_returns_current_settings(tmp_path: Path) -> None: + """GET returns the current persisted settings payload.""" + + prompt_server = FakePromptServer() + repository = SimpleSyrupSettingsRepository(tmp_path / "settings.json") + repository.save(SimpleSyrupSettings(show_downloadable_models=False)) + register_fake_routes(repository, prompt_server) + + response = asyncio.run(prompt_server.routes.get_handlers[SETTINGS_ROUTE](object())) + + assert response.status == 200 + assert json.loads(response_text(response)) == {"show_downloadable_models": False} + + +def test_post_settings_validates_and_persists_payload(tmp_path: Path) -> None: + """POST validates and saves settings.""" + + prompt_server = FakePromptServer() + repository = SimpleSyrupSettingsRepository(tmp_path / "settings.json") + register_fake_routes(repository, prompt_server) + + response = asyncio.run( + prompt_server.routes.post_handlers[SETTINGS_ROUTE]( + FakeRequest({"show_downloadable_models": False}) + ) + ) + + assert response.status == 200 + assert json.loads(response_text(response)) == {"show_downloadable_models": False} + assert repository.load().show_downloadable_models is False + + +def test_post_settings_rejects_non_boolean_payload(tmp_path: Path) -> None: + """POST rejects malformed setting values.""" + + prompt_server = FakePromptServer() + repository = SimpleSyrupSettingsRepository(tmp_path / "settings.json") + register_fake_routes(repository, prompt_server) + + response = asyncio.run( + prompt_server.routes.post_handlers[SETTINGS_ROUTE]( + FakeRequest({"show_downloadable_models": "false"}) + ) + ) + + assert response.status == 400 + assert "show_downloadable_models" in response_text(response) + + +def test_post_settings_rejects_invalid_json(tmp_path: Path) -> None: + """POST rejects bodies that cannot be decoded as JSON.""" + + prompt_server = FakePromptServer() + repository = SimpleSyrupSettingsRepository(tmp_path / "settings.json") + register_fake_routes(repository, prompt_server) + + response = asyncio.run( + prompt_server.routes.post_handlers[SETTINGS_ROUTE]( + FakeRequest(ValueError("bad json")) + ) + ) + + assert response.status == 400 + assert "valid JSON" in response_text(response) + + +def register_fake_routes( + repository: SimpleSyrupSettingsRepository, + prompt_server: FakePromptServer, +) -> bool: + """Register routes against a fake PromptServer with structural typing.""" + + return register_settings_routes( + repository, + cast(PromptServerProtocol, prompt_server), + ) + + +def response_text(response: web.Response) -> str: + """Return response text after asserting aiohttp populated it.""" + + assert response.text is not None + return response.text diff --git a/tests/test_simple_load_anima_node.py b/tests/test_simple_load_anima_node.py new file mode 100644 index 0000000..9855416 --- /dev/null +++ b/tests/test_simple_load_anima_node.py @@ -0,0 +1,113 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Simple Load Anima node declaration.""" + +from __future__ import annotations + +import sys +from types import ModuleType +from typing import Any + +import pytest + +from simple_syrup.nodes.simple_load_anima import SimpleLoadAnima + + +class FakeFolderPaths(ModuleType): + """Small folder_paths fake for node input declarations.""" + + def __init__(self) -> None: + """Create deterministic ComfyUI filename lists.""" + + super().__init__("folder_paths") + self.files = { + "diffusion_models": ["anima.safetensors"], + "text_encoders": ["qwen\\qwen_3_06b_base.safetensors"], + "vae": ["qwen\\qwen_image_vae.safetensors"], + "vae_approx": [], + } + + def get_filename_list(self, folder_name: str) -> list[str]: + """Return deterministic filenames for a model folder.""" + + return self.files[folder_name] + + +def test_simple_load_anima_contract() -> None: + """Simple Load Anima exposes MODEL, CLIP, and VAE sockets.""" + + assert SimpleLoadAnima.RETURN_TYPES == ("MODEL", "CLIP", "VAE") + assert SimpleLoadAnima.RETURN_NAMES == ("model", "clip", "vae") + assert SimpleLoadAnima.FUNCTION == "load_models" + assert SimpleLoadAnima.CATEGORY == "SimpleSyrup/Loaders" + + +def test_simple_load_anima_declares_expected_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Input declarations mirror the combined ComfyUI loader controls.""" + + monkeypatch.setitem(sys.modules, "folder_paths", FakeFolderPaths()) + + input_types: dict[str, dict[str, tuple[Any, ...]]] = SimpleLoadAnima.INPUT_TYPES() + required = input_types["required"] + + assert list(required) == [ + "diffusion_model", + "diffusion_weight_dtype", + "text_encoder", + "text_encoder_device", + "vae", + ] + assert required["diffusion_model"][0] == ["anima.safetensors"] + assert required["diffusion_weight_dtype"][0] == [ + "default", + "fp8_e4m3fn", + "fp8_e4m3fn_fast", + "fp8_e5m2", + ] + assert required["text_encoder"][0][0] == "auto" + assert required["text_encoder"][1]["default"] == "auto" + assert required["text_encoder_device"][0] == ["default", "cpu"] + assert required["vae"][0][0] == "auto" + assert required["vae"][1]["default"] == "auto" + + +def test_simple_load_anima_delegates_to_service() -> None: + """Node execution delegates to the Anima loader service.""" + + class FakeService: + """Service double for node execution.""" + + def __init__(self) -> None: + """Create a recording service double.""" + + self.kwargs: dict[str, object] | None = None + + def load_models(self, **kwargs: object) -> tuple[str, str, str]: + """Record call arguments and return fixed outputs.""" + + self.kwargs = kwargs + return ("model", "clip", "vae") + + fake_service = FakeService() + original = SimpleLoadAnima._service + SimpleLoadAnima._service = fake_service # type: ignore[assignment] + try: + result = SimpleLoadAnima().load_models( + diffusion_model="anima.safetensors", + diffusion_weight_dtype="default", + text_encoder="auto", + text_encoder_device="default", + vae="auto", + ) + finally: + SimpleLoadAnima._service = original + + assert result == ("model", "clip", "vae") + assert fake_service.kwargs is not None + assert fake_service.kwargs["text_encoder"] == "auto" + assert fake_service.kwargs["vae"] == "auto" + assert "progress" in fake_service.kwargs diff --git a/tests/test_simple_load_checkpoint_node.py b/tests/test_simple_load_checkpoint_node.py new file mode 100644 index 0000000..cb048bd --- /dev/null +++ b/tests/test_simple_load_checkpoint_node.py @@ -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 + +"""Tests for the Simple Load Checkpoint node declaration.""" + +from __future__ import annotations + +import sys +from types import ModuleType +from typing import Any + +import pytest + +from simple_syrup.nodes.simple_load_checkpoint import SimpleLoadCheckpoint +from simple_syrup.runtime.checkpoint_loader import ( + CLIP_SKIP_DEFAULT, + USE_CHECKPOINT_VAE_CHOICE, +) + + +class FakeFolderPaths(ModuleType): + """Small folder_paths fake for checkpoint node input declarations.""" + + def __init__(self) -> None: + """Create deterministic ComfyUI filename lists.""" + + super().__init__("folder_paths") + self.files = { + "checkpoints": ["model.safetensors"], + "vae": ["manual_vae.safetensors", USE_CHECKPOINT_VAE_CHOICE], + "vae_approx": [], + } + + def get_filename_list(self, folder_name: str) -> list[str]: + """Return deterministic filenames for a model folder.""" + + return self.files[folder_name] + + +def test_simple_load_checkpoint_contract() -> None: + """Simple Load Checkpoint exposes MODEL, CLIP, and VAE sockets.""" + + assert SimpleLoadCheckpoint.RETURN_TYPES == ("MODEL", "CLIP", "VAE") + assert SimpleLoadCheckpoint.RETURN_NAMES == ("model", "clip", "vae") + assert SimpleLoadCheckpoint.FUNCTION == "load_checkpoint" + assert SimpleLoadCheckpoint.CATEGORY == "SimpleSyrup/Loaders" + + +def test_simple_load_checkpoint_declares_expected_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Input declarations mirror checkpoint loading plus VAE override.""" + + monkeypatch.setitem(sys.modules, "folder_paths", FakeFolderPaths()) + + input_types: dict[str, dict[str, tuple[Any, ...]]] = ( + SimpleLoadCheckpoint.INPUT_TYPES() + ) + required = input_types["required"] + + assert list(required) == ["ckpt_name", "vae_name", "clip_skip"] + assert required["ckpt_name"][0] == ["model.safetensors"] + assert required["vae_name"][0] == [ + USE_CHECKPOINT_VAE_CHOICE, + "manual_vae.safetensors", + "pixel_space", + ] + assert required["vae_name"][1]["default"] == USE_CHECKPOINT_VAE_CHOICE + assert required["clip_skip"][0] == "BOOLEAN" + assert required["clip_skip"][1]["default"] is CLIP_SKIP_DEFAULT + + +def test_simple_load_checkpoint_delegates_to_service() -> None: + """Node execution delegates to the checkpoint loader service.""" + + class FakeService: + """Service double for node execution.""" + + def __init__(self) -> None: + """Create a recording service double.""" + + self.kwargs: dict[str, object] | None = None + + def load_checkpoint(self, **kwargs: object) -> tuple[str, str, str]: + """Record call arguments and return fixed outputs.""" + + self.kwargs = kwargs + return ("model", "clip", "vae") + + fake_service = FakeService() + original = SimpleLoadCheckpoint._service + SimpleLoadCheckpoint._service = fake_service # type: ignore[assignment] + try: + result = SimpleLoadCheckpoint().load_checkpoint( + ckpt_name="model.safetensors", + vae_name=USE_CHECKPOINT_VAE_CHOICE, + clip_skip=True, + ) + finally: + SimpleLoadCheckpoint._service = original + + assert result == ("model", "clip", "vae") + assert fake_service.kwargs == { + "ckpt_name": "model.safetensors", + "vae_name": USE_CHECKPOINT_VAE_CHOICE, + "clip_skip": True, + } diff --git a/tests/test_simple_load_checkpoint_v3_node.py b/tests/test_simple_load_checkpoint_v3_node.py new file mode 100644 index 0000000..e7eff51 --- /dev/null +++ b/tests/test_simple_load_checkpoint_v3_node.py @@ -0,0 +1,102 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Simple Load Checkpoint Comfy v3 wrapper.""" + +from __future__ import annotations + +import sys +from types import ModuleType +from typing import Any + +import pytest + +from simple_syrup.nodes.simple_load_checkpoint import SimpleLoadCheckpoint +from simple_syrup.nodes_v3.simple_load_checkpoint import SimpleLoadCheckpointV3 +from simple_syrup.runtime.checkpoint_loader import ( + CLIP_SKIP_DEFAULT, + USE_CHECKPOINT_VAE_CHOICE, +) + + +class FakeFolderPaths(ModuleType): + """Small folder_paths fake for v3 schema declarations.""" + + def __init__(self) -> None: + """Create deterministic ComfyUI filename lists.""" + + super().__init__("folder_paths") + self.files = { + "checkpoints": ["model.safetensors"], + "vae": ["manual_vae.safetensors"], + "vae_approx": [], + } + + def get_filename_list(self, folder_name: str) -> list[str]: + """Return deterministic filenames for a model folder.""" + + return self.files[folder_name] + + +def test_simple_load_checkpoint_v3_schema( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The v3 schema mirrors the legacy checkpoint loader contract.""" + + monkeypatch.setitem(sys.modules, "folder_paths", FakeFolderPaths()) + + schema = SimpleLoadCheckpointV3.define_schema() + + assert schema.node_id == "SimpleSyrup.SimpleLoadCheckpoint" + assert schema.display_name == "Simple Load Checkpoint" + assert [input_item.id for input_item in schema.inputs] == [ + "ckpt_name", + "vae_name", + "clip_skip", + ] + assert schema.inputs[0].options == ["model.safetensors"] + assert schema.inputs[1].options == [ + USE_CHECKPOINT_VAE_CHOICE, + "manual_vae.safetensors", + "pixel_space", + ] + assert schema.inputs[1].default == USE_CHECKPOINT_VAE_CHOICE + assert schema.inputs[2].io_type == "BOOLEAN" + assert schema.inputs[2].default is CLIP_SKIP_DEFAULT + assert [output.id for output in schema.outputs] == ["model", "clip", "vae"] + assert [output.io_type for output in schema.outputs] == ["MODEL", "CLIP", "VAE"] + + +def test_simple_load_checkpoint_v3_execute_forwards_to_legacy_loader( + monkeypatch: Any, +) -> None: + """The v3 wrapper forwards execution to the legacy loader.""" + + class FakeService: + """Service double for the legacy loader.""" + + def __init__(self) -> None: + """Initialize captured kwargs.""" + + self.kwargs: dict[str, object] = {} + + def load_checkpoint(self, **kwargs: object) -> tuple[str, str, str]: + """Return fixed checkpoint outputs.""" + + self.kwargs = kwargs + return ("model", "clip", "vae") + + fake_service = FakeService() + monkeypatch.setattr(SimpleLoadCheckpoint, "_service", fake_service) + + assert SimpleLoadCheckpointV3.execute( + "model.safetensors", + USE_CHECKPOINT_VAE_CHOICE, + True, + ) == ("model", "clip", "vae") + assert fake_service.kwargs == { + "ckpt_name": "model.safetensors", + "vae_name": USE_CHECKPOINT_VAE_CHOICE, + "clip_skip": True, + } diff --git a/tests/test_simple_vae_encode_node.py b/tests/test_simple_vae_encode_node.py new file mode 100644 index 0000000..8780214 --- /dev/null +++ b/tests/test_simple_vae_encode_node.py @@ -0,0 +1,92 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Simple VAE Encode node.""" + +from __future__ import annotations + +from typing import Any + +from simple_syrup.nodes.provenance_latent import SimpleVAEEncode + + +def test_simple_vae_encode_declares_raw_link_inputs() -> None: + """Simple VAE Encode exposes image and VAE inputs with raw graph links.""" + + inputs = SimpleVAEEncode.INPUT_TYPES() + + assert SimpleVAEEncode.RETURN_TYPES == ("LATENT",) + assert SimpleVAEEncode.RETURN_NAMES == ("latent",) + assert inputs["required"]["image"][0] == "IMAGE" + assert inputs["required"]["image"][1]["rawLink"] is True + assert inputs["required"]["vae"][0] == "VAE" + assert inputs["required"]["vae"][1]["rawLink"] is True + assert inputs["hidden"]["prompt"] == "PROMPT" + + +def test_simple_vae_encode_reuses_matching_decode_latent() -> None: + """Matching VAE decode provenance returns the original latent link.""" + + prompt = { + "decode": { + "class_type": "VAEDecode", + "inputs": {"samples": ["latent", 0], "vae": ["loader", 2]}, + } + } + + result = SimpleVAEEncode().encode(["decode", 0], ["loader", 2], prompt) + + assert result["expand"] == {} + assert result["result"] == (["latent", 0],) + + +def test_simple_vae_encode_falls_back_when_provenance_breaks() -> None: + """Broken provenance emits a fallback VAEEncode expansion.""" + + result = SimpleVAEEncode().encode( + ["edited", 0], + ["loader", 2], + { + "edited": { + "class_type": "ImageEdit", + "inputs": {"image": ["decode", 0]}, + } + }, + ) + + graph = result["expand"] + assert _single_node(graph)["class_type"] == "VAEEncode" + assert _single_node(graph)["inputs"] == { + "pixels": ["edited", 0], + "vae": ["loader", 2], + } + assert result["result"][0][0] in graph + assert result["result"][0][1] == 0 + + +def test_simple_vae_encode_falls_back_when_vae_differs() -> None: + """Mismatched VAE links use normal VAEEncode behavior.""" + + result = SimpleVAEEncode().encode( + ["decode", 0], + ["other_loader", 2], + { + "decode": { + "class_type": "VAEDecode", + "inputs": {"samples": ["latent", 0], "vae": ["loader", 2]}, + } + }, + ) + + node = _single_node(result["expand"]) + assert node["class_type"] == "VAEEncode" + assert node["inputs"]["pixels"] == ["decode", 0] + assert node["inputs"]["vae"] == ["other_loader", 2] + + +def _single_node(graph: dict[str, dict[str, Any]]) -> dict[str, Any]: + """Return the only node from a dynamic expansion graph.""" + + assert len(graph) == 1 + return next(iter(graph.values())) diff --git a/tests/test_text_box_detector.py b/tests/test_text_box_detector.py new file mode 100644 index 0000000..7fd7007 --- /dev/null +++ b/tests/test_text_box_detector.py @@ -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 + +"""Tests for GroundingDINO text box detector adaptation.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import ModuleType +from typing import Any, cast + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox +from simple_syrup.runtime.grounding_dino_loader import GROUNDING_DINO_RUNTIME_PACKAGE +from simple_syrup.runtime.loaded_models import LoadedGroundingDINOModel +from simple_syrup.runtime.model_device_manager import TorchModelDeviceManager +from simple_syrup.runtime.text_box_detector import ( + GroundingDINOTextBoxDetector, + TextBoxDetection, +) +from test_helpers import make_image_tensor + + +class PredictBoxesModel: + """DINO-style object exposing a direct predict_boxes method.""" + + def predict_boxes( + self, + image: torch.Tensor, + prompt: str, + threshold: float, + ) -> torch.Tensor: + """Return deterministic boxes.""" + + return torch.tensor([[0.0, 0.0, 2.0, 2.0]], dtype=torch.float32) + + +def test_detector_accepts_predict_boxes_protocol() -> None: + """Objects with predict_boxes are accepted with default confidence.""" + + result = GroundingDINOTextBoxDetector().detect( + PredictBoxesModel(), + make_image_tensor(batch_size=1, height=2, width=2)[0], + "face", + 0.3, + "cpu", + ) + + assert result == (TextBoxDetection(bbox=BoundingBox(0, 0, 2, 2), confidence=1.0),) + + +def test_detector_accepts_loaded_grounding_dino_model(tmp_path: Path) -> None: + """SimpleSyrup loaded DINO containers unwrap before adaptation.""" + + loaded = LoadedGroundingDINOModel( + model=PredictBoxesModel(), + text_encoder_path=tmp_path / "bert", + source="test", + model_id="dino", + ) + + result = GroundingDINOTextBoxDetector().detect( + loaded, + make_image_tensor(batch_size=1, height=2, width=2)[0], + "face", + 0.3, + "cpu", + ) + + assert len(result) == 1 + assert result[0].confidence == 1.0 + + +def test_detector_rejects_invalid_model() -> None: + """Invalid DINO objects fail with an actionable error.""" + + with pytest.raises(TypeError, match="Prompt SEGS w/ SAM"): + GroundingDINOTextBoxDetector().detect( + object(), + make_image_tensor(batch_size=1, height=2, width=2)[0], + "face", + 0.3, + "cpu", + ) + + +def test_detector_rejects_invalid_box_shape() -> None: + """Malformed predict_boxes output fails clearly.""" + + class InvalidBoxesModel: + """Return invalid boxes.""" + + def predict_boxes( + self, + image: torch.Tensor, + prompt: str, + threshold: float, + ) -> torch.Tensor: + """Return malformed boxes.""" + + return torch.ones((2, 3), dtype=torch.float32) + + with pytest.raises(ValueError, match="invalid boxes shape"): + GroundingDINOTextBoxDetector().detect( + InvalidBoxesModel(), + make_image_tensor(batch_size=1, height=2, width=2)[0], + "face", + 0.3, + "cpu", + ) + + +def test_detector_accepts_empty_boxes() -> None: + """Empty predict_boxes output returns no detections.""" + + class EmptyBoxesModel: + """Return no boxes.""" + + def predict_boxes( + self, + image: torch.Tensor, + prompt: str, + threshold: float, + ) -> torch.Tensor: + """Return an empty box tensor.""" + + return torch.empty((0, 4), dtype=torch.float32) + + result = GroundingDINOTextBoxDetector().detect( + EmptyBoxesModel(), + make_image_tensor(batch_size=1, height=2, width=2)[0], + "face", + 0.3, + "cpu", + ) + + assert result == () + + +def test_raw_grounding_dino_path_returns_scores( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Raw GroundingDINO detections preserve max-logit confidence.""" + + class FakeModel: + """Raw callable GroundingDINO double.""" + + def __call__( + self, + image: torch.Tensor, + captions: list[str], + ) -> dict[str, torch.Tensor]: + """Return one high-confidence normalized box.""" + + return { + "pred_logits": torch.tensor([[[0.0, 4.0], [-4.0, -4.0]]]), + "pred_boxes": torch.tensor( + [[[0.5, 0.5, 1.0, 1.0], [0.5, 0.5, 1.0, 1.0]]] + ), + } + + class FakeTransform: + """GroundingDINO transform double.""" + + def __call__( + self, + image: object, + target: object, + ) -> tuple[torch.Tensor, object]: + """Return a fake image tensor.""" + + return torch.zeros((3, 2, 2), dtype=torch.float32), target + + transforms = ModuleType(f"{GROUNDING_DINO_RUNTIME_PACKAGE}.datasets.transforms") + transforms.Compose = lambda steps: FakeTransform() # type: ignore[attr-defined] + transforms.RandomResize = lambda sizes, max_size: object() # type: ignore[attr-defined] + transforms.ToTensor = lambda: object() # type: ignore[attr-defined] + transforms.Normalize = lambda mean, std: object() # type: ignore[attr-defined] + comfy = ModuleType("comfy") + model_management = ModuleType("comfy.model_management") + model_management.get_torch_device = lambda: torch.device("cpu") # type: ignore[attr-defined] + monkeypatch.setitem( + sys.modules, + f"{GROUNDING_DINO_RUNTIME_PACKAGE}.datasets.transforms", + transforms, + ) + monkeypatch.setitem(sys.modules, "comfy", comfy) + monkeypatch.setitem(sys.modules, "comfy.model_management", model_management) + + result = GroundingDINOTextBoxDetector().detect( + FakeModel(), + make_image_tensor(batch_size=1, height=2, width=2)[0], + "face", + 0.5, + "cpu", + ) + + assert len(result) == 1 + assert result[0].bbox == BoundingBox(0, 0, 2, 2) + assert result[0].confidence == pytest.approx( + torch.sigmoid(torch.tensor(4.0)).item() + ) + + +def test_loaded_managed_grounding_dino_uses_comfy_device_context( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SimpleSyrup-loaded GroundingDINO moves model and image together for auto.""" + + class FakeModel: + """Raw callable GroundingDINO double with device recording.""" + + def __init__(self) -> None: + """Create a model fake.""" + + self.device: torch.device | None = torch.device("cpu") + self.image_devices: list[str] = [] + + def eval(self) -> None: + """Accept eval mode.""" + + def __call__( + self, + image: torch.Tensor, + captions: list[str], + ) -> dict[str, torch.Tensor]: + """Return no detections while recording the input device.""" + + _ = captions + self.image_devices.append(str(image.device)) + return { + "pred_logits": torch.zeros((1, 1, 1), dtype=torch.float32), + "pred_boxes": torch.zeros((1, 1, 4), dtype=torch.float32), + } + + class FakeModelPatcher: + """Fake Comfy patcher that places the model on the load device.""" + + def __init__( + self, + model: object, + load_device: torch.device, + offload_device: torch.device, + ) -> None: + """Record patcher state.""" + + self.model = model + self.load_device = load_device + self.offload_device = offload_device + cast(Any, model).device = load_device + + class FakeTransform: + """GroundingDINO transform double.""" + + def __call__( + self, + image: object, + target: object, + ) -> tuple[torch.Tensor, object]: + """Return a fake image tensor.""" + + return torch.zeros((3, 2, 2), dtype=torch.float32), target + + state: dict[str, list[object]] = {"loaded": []} + transforms = ModuleType(f"{GROUNDING_DINO_RUNTIME_PACKAGE}.datasets.transforms") + transforms.Compose = lambda steps: FakeTransform() # type: ignore[attr-defined] + transforms.RandomResize = lambda sizes, max_size: object() # type: ignore[attr-defined] + transforms.ToTensor = lambda: object() # type: ignore[attr-defined] + transforms.Normalize = lambda mean, std: object() # type: ignore[attr-defined] + comfy = ModuleType("comfy") + model_management = ModuleType("comfy.model_management") + model_patcher = ModuleType("comfy.model_patcher") + model_management.get_torch_device = lambda: torch.device("cpu") # type: ignore[attr-defined] + model_management.text_encoder_offload_device = lambda: torch.device("cpu") # type: ignore[attr-defined] + model_management.load_model_gpu = lambda patcher: state["loaded"].append(patcher) # type: ignore[attr-defined] + model_patcher.ModelPatcher = FakeModelPatcher # type: ignore[attr-defined] + monkeypatch.setitem( + sys.modules, + f"{GROUNDING_DINO_RUNTIME_PACKAGE}.datasets.transforms", + transforms, + ) + monkeypatch.setitem(sys.modules, "comfy", comfy) + monkeypatch.setitem(sys.modules, "comfy.model_management", model_management) + monkeypatch.setitem(sys.modules, "comfy.model_patcher", model_patcher) + + model = FakeModel() + managed = TorchModelDeviceManager().manage(model, "dino", "test") + loaded = LoadedGroundingDINOModel( + model=model, + text_encoder_path=tmp_path / "bert", + source="test", + model_id="dino", + managed_model=managed, + ) + + result = GroundingDINOTextBoxDetector().detect( + loaded, + make_image_tensor(batch_size=1, height=2, width=2)[0], + "face", + 0.5, + "auto", + ) + + assert result == () + assert model.image_devices == ["cpu"] diff --git a/tests/test_third_party_vendoring_contract.py b/tests/test_third_party_vendoring_contract.py new file mode 100644 index 0000000..9f16e55 --- /dev/null +++ b/tests/test_third_party_vendoring_contract.py @@ -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 + +"""Contract tests for vendored third-party provenance.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_third_party_manifest_references_existing_licenses_and_runtime_files() -> None: + """Vendored components should record license text and runtime file paths.""" + + manifest = tomllib.loads( + (REPO_ROOT / "third_party" / "manifest.toml").read_text(encoding="utf-8") + ) + + for component in manifest["component"]: + license_path = REPO_ROOT / component["license_file"] + assert license_path.is_file() + + for vendored_file in component["vendored_files"]: + assert (REPO_ROOT / vendored_file).is_file() + + +def test_third_party_notice_records_every_manifest_component() -> None: + """The notice file should identify each vendored component by name.""" + + manifest = tomllib.loads( + (REPO_ROOT / "third_party" / "manifest.toml").read_text(encoding="utf-8") + ) + notice = (REPO_ROOT / "third_party" / "NOTICE.md").read_text(encoding="utf-8") + + for component in manifest["component"]: + assert component["name"] in notice + + +def test_adapted_owned_files_point_to_third_party_provenance() -> None: + """Mixed-provenance owned files should carry a local third-party notice.""" + + manifest = tomllib.loads( + (REPO_ROOT / "third_party" / "manifest.toml").read_text(encoding="utf-8") + ) + + for component in manifest["component"]: + for vendored_file in component["vendored_files"]: + path = Path(vendored_file) + if path.parts[:2] == ("simple_syrup", "third_party"): + continue + + content = (REPO_ROOT / path).read_text(encoding="utf-8") + + assert "third_party/manifest.toml" in content, path + assert "third_party/NOTICE.md" in content, path + + +def test_res4lyf_beta57_provenance_is_recorded() -> None: + """The beta57 scheduler preset should trace to the inspected RES4LYF source.""" + + manifest = tomllib.loads( + (REPO_ROOT / "third_party" / "manifest.toml").read_text(encoding="utf-8") + ) + components = {component["name"]: component for component in manifest["component"]} + + res4lyf_beta57 = components["RES4LYF beta57 scheduler preset"] + license_path = REPO_ROOT / res4lyf_beta57["license_file"] + + assert res4lyf_beta57["license"] == "AGPL-3.0" + assert "GNU AFFERO GENERAL PUBLIC" in license_path.read_text(encoding="utf-8") + assert res4lyf_beta57["source"] == "https://github.com/ClownsharkBatwing/RES4LYF" + assert res4lyf_beta57["revision"] == "1c9bf61" + assert res4lyf_beta57["source_paths"] == [ + "sigmas.py", + "res4lyf.py", + "README.md", + ] + assert res4lyf_beta57["vendored_files"] == [ + "simple_syrup/runtime/sampling_schedulers.py", + ] + + +def test_automatic1111_sampler_integration_provenance_is_recorded() -> None: + """The A1111 sampler integration should trace to the inspected WebUI source.""" + + manifest = tomllib.loads( + (REPO_ROOT / "third_party" / "manifest.toml").read_text(encoding="utf-8") + ) + components = {component["name"]: component for component in manifest["component"]} + + automatic1111 = components["AUTOMATIC1111 Euler a sampler integration"] + license_path = REPO_ROOT / automatic1111["license_file"] + + assert automatic1111["license"] == "AGPL-3.0" + assert "GNU AFFERO GENERAL PUBLIC" in license_path.read_text(encoding="utf-8") + assert ( + automatic1111["source"] + == "https://github.com/AUTOMATIC1111/stable-diffusion-webui" + ) + assert automatic1111["revision"] == "0120768f" + assert automatic1111["source_paths"] == [ + "modules/sd_samplers_kdiffusion.py", + "modules/sd_samplers_common.py", + "modules/sd_schedulers.py", + "modules/rng.py", + ] + assert automatic1111["vendored_files"] == [ + "simple_syrup/runtime/a1111_sampling.py", + "simple_syrup/runtime/sampling_samplers.py", + "simple_syrup/runtime/sampling_schedulers.py", + ] + + +def test_k_diffusion_euler_ancestral_provenance_is_recorded() -> None: + """The A1111 sampler loop should trace to the inspected k-diffusion source.""" + + manifest = tomllib.loads( + (REPO_ROOT / "third_party" / "manifest.toml").read_text(encoding="utf-8") + ) + components = {component["name"]: component for component in manifest["component"]} + + k_diffusion = components["k-diffusion Euler ancestral sampler"] + license_path = REPO_ROOT / k_diffusion["license_file"] + + assert k_diffusion["license"] == "MIT" + assert "Copyright (c) 2022 Katherine Crowson" in license_path.read_text( + encoding="utf-8" + ) + assert k_diffusion["source"] == "https://github.com/crowsonkb/k-diffusion" + assert k_diffusion["revision"] == "ab527a9" + assert k_diffusion["source_paths"] == [ + "k_diffusion/sampling.py", + ] + assert k_diffusion["vendored_files"] == [ + "simple_syrup/runtime/a1111_sampling.py", + ] + + +def test_tiled_diffusion_provenance_is_recorded() -> None: + """The tiled denoising behavior should trace to the inspected extension.""" + + manifest = tomllib.loads( + (REPO_ROOT / "third_party" / "manifest.toml").read_text(encoding="utf-8") + ) + components = {component["name"]: component for component in manifest["component"]} + + tiled_diffusion = components[ + "Mixture of Diffusers and MultiDiffusion tiled diffusion behavior" + ] + license_path = REPO_ROOT / tiled_diffusion["license_file"] + + assert tiled_diffusion["license"] == "CC-BY-NC-SA-4.0" + assert "Attribution-NonCommercial-ShareAlike 4.0" in license_path.read_text( + encoding="utf-8" + ) + assert ( + tiled_diffusion["source"] + == "https://github.com/pkuliyi2015/multidiffusion-upscaler-for-automatic1111" + ) + assert tiled_diffusion["source_paths"] == [ + "tile_methods/abstractdiffusion.py", + "tile_methods/mixtureofdiffusers.py", + "tile_methods/multidiffusion.py", + "tile_utils/utils.py", + "scripts/tilediffusion.py", + ] + assert tiled_diffusion["vendored_files"] == [ + "simple_syrup/domain/regional_detailing.py", + "simple_syrup/domain/tiled_diffusion.py", + "simple_syrup/masking/regional_detailing_masks.py", + "simple_syrup/runtime/mixture_of_diffusers_sampling.py", + "simple_syrup/runtime/multidiffusion_sampling.py", + "simple_syrup/runtime/regional_multidiffusion_sampling.py", + "simple_syrup/runtime/tiled_sampling.py", + "simple_syrup/services/detail_segs_as_regions_service.py", + ] + + +def test_notice_records_sampler_and_tiled_diffusion_provenance() -> None: + """The notice file should include sampler and tiled diffusion provenance.""" + + notice = (REPO_ROOT / "third_party" / "NOTICE.md").read_text(encoding="utf-8") + + assert "AUTOMATIC1111 Euler a sampler integration" in notice + assert "k-diffusion Euler ancestral sampler" in notice + assert "Mixture of Diffusers and MultiDiffusion tiled diffusion behavior" in notice + assert "regional prompt mask blending" in notice diff --git a/tests/test_tile_and_tag_segs_node.py b/tests/test_tile_and_tag_segs_node.py new file mode 100644 index 0000000..ad25c46 --- /dev/null +++ b/tests/test_tile_and_tag_segs_node.py @@ -0,0 +1,136 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Tile & Tag SEGS node contract.""" + +from __future__ import annotations + +from typing import Any, cast + +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment +from simple_syrup.domain.tile_segs import TileSEGSControls +from simple_syrup.nodes.tile_and_tag_segs import ( + DEFAULT_EXCLUDE_TAGS, + TileAndTagSEGS, +) +from simple_syrup.runtime.wd14_tagger import WD14TagFormattingControls +from simple_syrup.services.tile_and_tag_segs_service import TileAndTagSEGSResult + + +def test_tile_and_tag_segs_contract() -> None: + """Tile & Tag SEGS exposes the agreed ComfyUI contract.""" + + inputs = TileAndTagSEGS.INPUT_TYPES() + + assert TileAndTagSEGS.RETURN_TYPES == ("SEGS", "CONDITIONING_BATCH") + assert TileAndTagSEGS.RETURN_NAMES == ("segs", "positive") + assert TileAndTagSEGS.FUNCTION == "tile_and_tag" + assert TileAndTagSEGS.CATEGORY == "SimpleSyrup/Detailing" + assert list(inputs["required"]) == [ + "image", + "clip", + "wd14_tagger", + "universal_positive", + "bbox_size", + "crop_factor", + "min_overlap", + "filter_segs_dilation", + "mask_irregularity", + "irregular_mask_mode", + "threshold", + "character_threshold", + "replace_underscore", + "trailing_comma", + "exclude_tags", + ] + assert "optional" not in inputs + assert inputs["required"]["wd14_tagger"][0] == "WD14_TAGGER" + assert inputs["required"]["universal_positive"][0] == "STRING" + assert inputs["required"]["universal_positive"][1]["default"] == "" + assert inputs["required"]["universal_positive"][1]["multiline"] is False + assert inputs["required"]["bbox_size"][1]["default"] == 872 + assert inputs["required"]["crop_factor"][1]["default"] == 1.1 + assert inputs["required"]["min_overlap"][1]["default"] == 16 + assert inputs["required"]["filter_segs_dilation"][1]["default"] == 20 + assert inputs["required"]["mask_irregularity"][1]["default"] == 0 + assert inputs["required"]["irregular_mask_mode"][0][0] == "Reuse fast" + assert inputs["required"]["threshold"][1]["default"] == 0.35 + assert inputs["required"]["character_threshold"][1]["default"] == 1.0 + assert inputs["required"]["replace_underscore"][1]["default"] is True + assert inputs["required"]["trailing_comma"][1]["default"] is False + assert inputs["required"]["exclude_tags"][1]["default"] == DEFAULT_EXCLUDE_TAGS + assert inputs["required"]["exclude_tags"][1]["multiline"] is False + + +def test_tile_and_tag_segs_delegates_to_service( + monkeypatch: Any, +) -> None: + """The node delegates behavior and returns service outputs unchanged.""" + + service = _FakeService() + monkeypatch.setattr(TileAndTagSEGS, "service_class", lambda: service) + image = torch.zeros((1, 8, 8, 3)) + wd14_tagger = object() + + segs, positive = TileAndTagSEGS().tile_and_tag( + image=image, + clip="clip", + wd14_tagger=wd14_tagger, + universal_positive="masterpiece", + bbox_size=872, + crop_factor=1.1, + min_overlap=16, + filter_segs_dilation=20, + mask_irregularity=0.0, + irregular_mask_mode="Reuse fast", + threshold=0.35, + character_threshold=1.0, + replace_underscore=True, + trailing_comma=False, + exclude_tags=DEFAULT_EXCLUDE_TAGS, + ) + + assert segs is service.result.segs + assert positive is service.result.positive + assert service.call["image"] is image + assert service.call["clip"] == "clip" + assert service.call["wd14_tagger"] is wd14_tagger + assert service.call["universal_positive"] == "masterpiece" + tile_controls = cast(TileSEGSControls, service.call["tile_controls"]) + tag_controls = cast(WD14TagFormattingControls, service.call["tag_controls"]) + assert tile_controls.bbox_size == 872 + assert tag_controls.threshold == 0.35 + + +class _FakeService: + """Capture node calls for delegation tests.""" + + def __init__(self) -> None: + """Create a fake service result.""" + + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((8, 8)), + confidence=1.0, + crop_region=CropRegion(0, 0, 8, 8), + bbox=BoundingBox(0, 0, 8, 8), + label="tile_001", + ) + self.result = TileAndTagSEGSResult( + segs=((8, 8), [segment]), + positive=ConditioningBatch(("encoded",)), + ) + self.call: dict[str, object] = {} + + def tile_and_tag( + self, + **kwargs: object, + ) -> TileAndTagSEGSResult: + """Return a fixed result and remember provided inputs.""" + + self.call = kwargs + return self.result diff --git a/tests/test_tile_and_tag_segs_service.py b/tests/test_tile_and_tag_segs_service.py new file mode 100644 index 0000000..90b47f2 --- /dev/null +++ b/tests/test_tile_and_tag_segs_service.py @@ -0,0 +1,367 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Tile & Tag SEGS service orchestration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.domain.tile_segs import TileSEGSControls +from simple_syrup.runtime.loaded_models import LoadedWD14Tagger +from simple_syrup.runtime.wd14_tagger import ( + FloatArray, + WD14TagFormattingControls, + WD14TagRecord, +) +from simple_syrup.services.tile_and_tag_segs_service import TileAndTagSEGSService + + +def test_service_preserves_segs_tag_and_conditioning_order() -> None: + """SEGS, tile crops, tags, and conditioning stay aligned by index.""" + + progress = _ProgressRecorder() + tiler = _FakeTiler(_native_segs(("first", "second", "third"))) + tagger = _FakeTagger(("tag first", "", "tag third")) + encoder = _FakeEncoder() + loaded_tagger = _loaded_tagger() + service = TileAndTagSEGSService( + tiler=tiler, + tagger=tagger, + encoder=encoder, + progress_factory=lambda _total: progress, + ) + + result = service.tile_and_tag( + image=_image(), + clip="clip", + wd14_tagger=loaded_tagger, + tile_controls=_tile_controls(), + tag_controls=_tag_controls(), + universal_positive="", + ) + + assert [segment.label for segment in result.segs[1]] == [ + "first", + "second", + "third", + ] + assert [tuple(crop.shape) for crop in tagger.crops] == [ + (1, 2, 2, 3), + (1, 2, 2, 3), + (1, 2, 2, 3), + ] + assert tagger.loaded_tagger is loaded_tagger + assert encoder.chunks == ("tag first", "", "tag third") + assert result.positive.entries == ( + "clip:tag first", + "clip:", + "clip:tag third", + ) + assert progress.updates == [1, 3, 1] + + +def test_service_prefixes_every_tile_prompt_with_universal_positive() -> None: + """Universal positive text is composed before conditioning encoding.""" + + tiler = _FakeTiler(_native_segs(("first", "second", "third"))) + tagger = _FakeTagger(("tag first", "", "tag third")) + encoder = _FakeEncoder() + service = TileAndTagSEGSService( + tiler=tiler, + tagger=tagger, + encoder=encoder, + ) + + result = service.tile_and_tag( + image=_image(), + clip="clip", + wd14_tagger=_loaded_tagger(), + tile_controls=_tile_controls(), + tag_controls=_tag_controls(), + universal_positive="masterpiece", + ) + + assert [segment.label for segment in result.segs[1]] == [ + "first", + "second", + "third", + ] + assert encoder.chunks == ( + "masterpiece, tag first", + "masterpiece", + "masterpiece, tag third", + ) + assert result.positive.entries == ( + "clip:masterpiece, tag first", + "clip:masterpiece", + "clip:masterpiece, tag third", + ) + + +def test_service_logs_universal_positive_presence( + caplog: pytest.LogCaptureFixture, +) -> None: + """Completion logging records whether a universal positive was supplied.""" + + service = TileAndTagSEGSService( + tiler=_FakeTiler(_native_segs(("first",))), + tagger=_FakeTagger(("tag first",)), + encoder=_FakeEncoder(), + ) + + with caplog.at_level("INFO"): + service.tile_and_tag( + image=_image(), + clip="clip", + wd14_tagger=_loaded_tagger(), + tile_controls=_tile_controls(), + tag_controls=_tag_controls(), + universal_positive=" masterwork ", + ) + + assert any( + record.__dict__.get("universal_positive_present") is True + for record in caplog.records + ) + assert any( + record.__dict__.get("wd14_model") == "wd-eva02-large-tagger-v3" + for record in caplog.records + ) + + +def test_service_rejects_tagger_count_mismatch() -> None: + """Dropping a tag would break SEGS alignment and is rejected.""" + + service = TileAndTagSEGSService( + tiler=_FakeTiler(_native_segs(("first", "second"))), + tagger=_FakeTagger(("only one",)), + encoder=_FakeEncoder(), + ) + + with pytest.raises(ValueError, match="returned 1 tag"): + service.tile_and_tag( + image=_image(), + clip="clip", + wd14_tagger=_loaded_tagger(), + tile_controls=_tile_controls(), + tag_controls=_tag_controls(), + universal_positive="", + ) + + +def test_service_rejects_empty_segs() -> None: + """A fully filtered image fails with an actionable error.""" + + service = TileAndTagSEGSService( + tiler=_FakeTiler(((4, 4), ())), + tagger=_FakeTagger(()), + encoder=_FakeEncoder(), + ) + + with pytest.raises(ValueError, match="No tile SEGS"): + service.tile_and_tag( + image=_image(), + clip="clip", + wd14_tagger=_loaded_tagger(), + tile_controls=_tile_controls(), + tag_controls=_tag_controls(), + universal_positive="", + ) + + +def test_service_rejects_invalid_wd14_tagger() -> None: + """Tile & Tag SEGS requires a loader-produced WD14_TAGGER object.""" + + service = TileAndTagSEGSService( + tiler=_FakeTiler(_native_segs(("first",))), + tagger=_FakeTagger(("tag first",)), + encoder=_FakeEncoder(), + ) + + with pytest.raises(TypeError, match="Load WD14 Tagger"): + service.tile_and_tag( + image=_image(), + clip="clip", + wd14_tagger=object(), + tile_controls=_tile_controls(), + tag_controls=_tag_controls(), + universal_positive="", + ) + + +def test_conditioning_batch_selects_by_segment_order() -> None: + """Detailer selection uses the same ordered batch produced by the service.""" + + batch = ConditioningBatch(("first-cond", "second-cond")) + + assert batch.select(0) == "first-cond" + assert batch.select(1) == "second-cond" + assert batch.select(2) == "second-cond" + + +class _FakeTiler: + """Return fixed native SEGS while capturing controls.""" + + def __init__(self, segs: NativeSegs) -> None: + """Store the fixed SEGS payload.""" + + self.segs = segs + self.calls: list[object] = [] + + def build( + self, + image: torch.Tensor, + controls: TileSEGSControls, + ) -> NativeSegs: + """Return the configured SEGS payload.""" + + self.calls.append((image, controls)) + return self.segs + + +class _FakeTagger: + """Return fixed tag strings for ordered crops.""" + + def __init__(self, tags: tuple[str, ...]) -> None: + """Store the fixed tags.""" + + self.tags = tags + self.crops: tuple[torch.Tensor, ...] = () + self.loaded_tagger: LoadedWD14Tagger | None = None + + def tag_images( + self, + loaded_tagger: LoadedWD14Tagger, + images: tuple[torch.Tensor, ...], + controls: WD14TagFormattingControls, + progress: object | None = None, + ) -> tuple[str, ...]: + """Return fixed tags and remember the crop order.""" + + _ = controls + if progress is not None: + progress.update(len(images)) # type: ignore[attr-defined] + self.loaded_tagger = loaded_tagger + self.crops = images + return self.tags + + +class _FakeEncoder: + """Return visible conditioning values for prompt chunks.""" + + def __init__(self) -> None: + """Initialize captured chunks.""" + + self.chunks: tuple[str, ...] = () + + def encode_batch(self, clip: Any, chunks: tuple[str, ...]) -> ConditioningBatch: + """Encode prompts as simple strings.""" + + self.chunks = chunks + return ConditioningBatch(tuple(f"{clip}:{chunk}" for chunk in chunks)) + + +class _ProgressRecorder: + """Record service progress updates.""" + + def __init__(self) -> None: + """Initialize captured update values.""" + + self.updates: list[int] = [] + + def update(self, value: int) -> None: + """Record one progress advance.""" + + self.updates.append(value) + + +def _native_segs(labels: tuple[str, ...]) -> NativeSegs: + """Create native SEGS with stable two-pixel crop regions.""" + + segments = tuple( + Segment( + cropped_image=None, + cropped_mask=torch.ones((2, 2)), + confidence=1.0, + crop_region=CropRegion(index, index, index + 2, index + 2), + bbox=BoundingBox(index, index, index + 2, index + 2), + label=label, + ) + for index, label in enumerate(labels) + ) + return (4, 4), segments + + +def _image() -> torch.Tensor: + """Return a small deterministic BHWC image.""" + + return torch.arange(4 * 4 * 3, dtype=torch.float32).reshape(1, 4, 4, 3) / 255.0 + + +def _tile_controls() -> TileSEGSControls: + """Return valid tile controls for service tests.""" + + return TileSEGSControls( + bbox_size=2, + crop_factor=1.0, + min_overlap=0, + filter_segs_dilation=0, + mask_irregularity=0.0, + irregular_mask_mode="Reuse fast", + ) + + +def _tag_controls() -> WD14TagFormattingControls: + """Return valid WD14 controls for service tests.""" + + return WD14TagFormattingControls( + threshold=0.35, + character_threshold=1.0, + replace_underscore=True, + trailing_comma=False, + exclude_tags="", + ) + + +def _loaded_tagger() -> LoadedWD14Tagger: + """Return a reusable loaded WD14 tagger test container.""" + + return LoadedWD14Tagger( + model_id="wd-eva02-large-tagger-v3", + source="test", + onnx_path=Path("wd-eva02-large-tagger-v3.onnx"), + csv_path=Path("wd-eva02-large-tagger-v3.csv"), + providers=("CPUExecutionProvider",), + session=_FakeWD14Session(), + tags=(WD14TagRecord("blue_hair", "0"),), + ) + + +class _FakeWD14Session: + """Minimal WD14 session test double.""" + + def get_inputs(self) -> list[object]: + """Return no fake inputs.""" + + return [] + + def get_outputs(self) -> list[object]: + """Return no fake outputs.""" + + return [] + + def run( + self, output_names: list[str], feeds: dict[str, FloatArray] + ) -> list[object]: + """Return no fake outputs.""" + + _ = output_names, feeds + return [] diff --git a/tests/test_tile_and_tag_segs_v3_node.py b/tests/test_tile_and_tag_segs_v3_node.py new file mode 100644 index 0000000..9c7c31b --- /dev/null +++ b/tests/test_tile_and_tag_segs_v3_node.py @@ -0,0 +1,106 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Tile & Tag SEGS Comfy v3 wrapper.""" + +from __future__ import annotations + +from typing import Any + +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment +from simple_syrup.nodes.tile_and_tag_segs import TileAndTagSEGS +from simple_syrup.nodes_v3.tile_and_tag_segs import TileAndTagSEGSV3 +from simple_syrup.services.tile_and_tag_segs_service import TileAndTagSEGSResult + + +def test_tile_and_tag_segs_v3_schema_includes_wd14_tagger() -> None: + """The v3 schema mirrors the legacy loaded-WD14 node contract.""" + + schema = TileAndTagSEGSV3.define_schema() + + assert schema.node_id == "SimpleSyrup.TileAndTagSEGS" + assert schema.display_name == "Tile & Tag SEGS" + assert [input_item.id for input_item in schema.inputs][:3] == [ + "image", + "clip", + "wd14_tagger", + ] + assert schema.inputs[2].io_type == "WD14_TAGGER" + universal_positive = schema.inputs[3] + assert universal_positive.io_type == "STRING" + assert universal_positive.default == "" + assert universal_positive.multiline is False + assert "model" not in [input_item.id for input_item in schema.inputs] + assert [output.id for output in schema.outputs] == ["segs", "positive"] + assert [output.io_type for output in schema.outputs] == [ + "SEGS", + "CONDITIONING_BATCH", + ] + + +def test_tile_and_tag_segs_v3_execute_forwards_universal_positive( + monkeypatch: Any, +) -> None: + """The v3 wrapper forwards universal_positive to the legacy implementation.""" + + service = _FakeService() + monkeypatch.setattr(TileAndTagSEGS, "service_class", lambda: service) + image = torch.zeros((1, 8, 8, 3)) + wd14_tagger = object() + + segs, positive = TileAndTagSEGSV3.execute( + image=image, + clip="clip", + wd14_tagger=wd14_tagger, + universal_positive="masterpiece", + bbox_size=872, + crop_factor=1.1, + min_overlap=16, + filter_segs_dilation=20, + mask_irregularity=0.0, + irregular_mask_mode="Reuse fast", + threshold=0.35, + character_threshold=1.0, + replace_underscore=True, + trailing_comma=False, + exclude_tags="", + ) + + assert segs is service.result.segs + assert positive is service.result.positive + assert service.call["wd14_tagger"] is wd14_tagger + assert service.call["universal_positive"] == "masterpiece" + + +class _FakeService: + """Capture v3 wrapper calls through the legacy node.""" + + def __init__(self) -> None: + """Create a fake service result.""" + + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((8, 8)), + confidence=1.0, + crop_region=CropRegion(0, 0, 8, 8), + bbox=BoundingBox(0, 0, 8, 8), + label="tile_001", + ) + self.result = TileAndTagSEGSResult( + segs=((8, 8), [segment]), + positive=ConditioningBatch(("encoded",)), + ) + self.call: dict[str, object] = {} + + def tile_and_tag( + self, + **kwargs: object, + ) -> TileAndTagSEGSResult: + """Return a fixed result and remember provided inputs.""" + + self.call = kwargs + return self.result diff --git a/tests/test_tile_segs_domain.py b/tests/test_tile_segs_domain.py new file mode 100644 index 0000000..9433d7d --- /dev/null +++ b/tests/test_tile_segs_domain.py @@ -0,0 +1,112 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for tile SEGS domain construction.""" + +from __future__ import annotations + +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion +from simple_syrup.domain.tile_segs import TileSEGSBuilder, TileSEGSControls + + +def test_small_image_creates_one_clamped_tile() -> None: + """A bbox larger than the image clamps to one full-image tile.""" + + segs = TileSEGSBuilder().build(_image(6, 6), _controls(bbox_size=64)) + + assert segs[0] == (6, 6) + assert len(segs[1]) == 1 + assert segs[1][0].bbox == BoundingBox(0, 0, 6, 6) + assert segs[1][0].crop_region == CropRegion(0, 0, 6, 6) + + +def test_tiles_are_row_major_and_cover_edges() -> None: + """Tile generation is deterministic and reaches image boundaries.""" + + segs = TileSEGSBuilder().build( + _image(6, 10), + _controls(bbox_size=4, min_overlap=1), + ) + + assert [segment.bbox for segment in segs[1]] == [ + BoundingBox(0, 0, 4, 4), + BoundingBox(2, 0, 6, 4), + BoundingBox(4, 0, 8, 4), + BoundingBox(6, 0, 10, 4), + BoundingBox(0, 2, 4, 6), + BoundingBox(2, 2, 6, 6), + BoundingBox(4, 2, 8, 6), + BoundingBox(6, 2, 10, 6), + ] + assert [segment.label for segment in segs[1]] == [ + "tile_001", + "tile_002", + "tile_003", + "tile_004", + "tile_005", + "tile_006", + "tile_007", + "tile_008", + ] + + +def test_irregular_reuse_mode_reuses_mask_pattern() -> None: + """Reuse modes apply the same generated mask pattern to each tile.""" + + segs = TileSEGSBuilder().build( + _image(4, 8), + _controls(bbox_size=4, mask_irregularity=0.5, irregular_mask_mode="Reuse fast"), + ) + + assert torch.equal( + torch.as_tensor(segs[1][0].cropped_mask), + torch.as_tensor(segs[1][1].cropped_mask), + ) + + +def test_irregular_all_random_mode_varies_mask_pattern() -> None: + """All-random modes generate a distinct mask pattern for each tile.""" + + segs = TileSEGSBuilder().build( + _image(4, 8), + _controls( + bbox_size=4, + mask_irregularity=0.5, + irregular_mask_mode="All random fast", + ), + ) + + assert not torch.equal( + torch.as_tensor(segs[1][0].cropped_mask), + torch.as_tensor(segs[1][1].cropped_mask), + ) + + +def _image(height: int, width: int) -> torch.Tensor: + """Return a black BHWC image.""" + + return torch.zeros((1, height, width, 3), dtype=torch.float32) + + +def _controls( + *, + bbox_size: int, + crop_factor: float = 1.0, + min_overlap: int = 0, + filter_segs_dilation: int = 0, + mask_irregularity: float = 0.0, + irregular_mask_mode: str = "Reuse fast", +) -> TileSEGSControls: + """Return valid tile controls for tests.""" + + return TileSEGSControls( + bbox_size=bbox_size, + crop_factor=crop_factor, + min_overlap=min_overlap, + filter_segs_dilation=filter_segs_dilation, + mask_irregularity=mask_irregularity, + irregular_mask_mode=irregular_mask_mode, + ) diff --git a/tests/test_tiled_diffusion_domain.py b/tests/test_tiled_diffusion_domain.py new file mode 100644 index 0000000..1aa3dc9 --- /dev/null +++ b/tests/test_tiled_diffusion_domain.py @@ -0,0 +1,212 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Mixture of Diffusers tiled diffusion domain behavior.""" + +from __future__ import annotations + +import math + +import pytest +import torch + +from simple_syrup.domain.tiled_diffusion import ( + TILED_DIFFUSION_MODES, + LatentTile, + build_tiled_diffusion_plan, + gaussian_tile_weights, + tile_is_splittable, + validate_tiled_diffusion_mode, +) + + +def test_tiled_diffusion_modes_match_node_contract() -> None: + """Supported tiled diffusion modes are stable workflow-facing values.""" + + assert TILED_DIFFUSION_MODES == ("multidiffusion", "mixture_of_diffusers") + + +@pytest.mark.parametrize("diffusion_mode", TILED_DIFFUSION_MODES) +def test_validate_tiled_diffusion_mode_accepts_supported_modes( + diffusion_mode: str, +) -> None: + """Supported tiled diffusion modes pass validation.""" + + validate_tiled_diffusion_mode(diffusion_mode) + + +def test_validate_tiled_diffusion_mode_rejects_unsupported_mode() -> None: + """Unsupported modes fail with an actionable field-specific error.""" + + with pytest.raises(ValueError) as exc_info: + validate_tiled_diffusion_mode("full_latent") + + message = str(exc_info.value) + assert "diffusion_mode" in message + assert "multidiffusion" in message + assert "mixture_of_diffusers" in message + assert "full_latent" in message + + +def test_plan_clamps_tile_size_to_latent_dimensions() -> None: + """Requested tiles larger than the latent are clamped to latent dimensions.""" + + plan = build_tiled_diffusion_plan( + latent_width=12, + latent_height=8, + tile_width=96, + tile_height=96, + overlap=48, + tile_batch_size=4, + ) + + assert plan.tile_width == 12 + assert plan.tile_height == 8 + assert plan.overlap == 48 + assert plan.tiles == (LatentTile(0, 0, 12, 8),) + assert not tile_is_splittable(12, 8, 96, 96, 48) + + +def test_plan_clamps_overlap_against_requested_tile_dimensions() -> None: + """Overlap clamps against requested tile dimensions.""" + + plan = build_tiled_diffusion_plan( + latent_width=8, + latent_height=8, + tile_width=96, + tile_height=96, + overlap=200, + tile_batch_size=4, + ) + + assert plan.tile_width == 8 + assert plan.tile_height == 8 + assert plan.overlap == 92 + + +def test_plan_generates_row_major_symmetric_tiles() -> None: + """Tile positions follow deterministic row-major symmetric grid math.""" + + plan = build_tiled_diffusion_plan( + latent_width=20, + latent_height=12, + tile_width=8, + tile_height=6, + overlap=2, + tile_batch_size=3, + ) + + assert plan.tiles == ( + LatentTile(0, 0, 8, 6), + LatentTile(6, 0, 8, 6), + LatentTile(12, 0, 8, 6), + LatentTile(0, 3, 8, 6), + LatentTile(6, 3, 8, 6), + LatentTile(12, 3, 8, 6), + LatentTile(0, 6, 8, 6), + LatentTile(6, 6, 8, 6), + LatentTile(12, 6, 8, 6), + ) + assert tile_is_splittable(20, 12, 8, 6, 2) + + +def test_plan_covers_non_divisible_latent_edges() -> None: + """Edge tiles clamp to the latent boundary and cover every pixel.""" + + plan = build_tiled_diffusion_plan( + latent_width=17, + latent_height=11, + tile_width=7, + tile_height=5, + overlap=1, + tile_batch_size=2, + ) + coverage = torch.zeros((plan.latent_height, plan.latent_width), dtype=torch.bool) + + for tile in plan.tiles: + coverage[tile.y : tile.y + tile.height, tile.x : tile.x + tile.width] = True + assert tile.x + tile.width <= plan.latent_width + assert tile.y + tile.height <= plan.latent_height + + assert coverage.all() + + +def test_plan_uses_balanced_effective_batch_sizing() -> None: + """Tile batches are balanced with an effective tile batch size.""" + + plan = build_tiled_diffusion_plan( + latent_width=20, + latent_height=12, + tile_width=8, + tile_height=6, + overlap=2, + tile_batch_size=4, + ) + + assert plan.tile_batch_size == 3 + assert tuple(len(batch) for batch in plan.batches) == (3, 3, 3) + + +def test_gaussian_tile_weights_match_reference_formula() -> None: + """Gaussian tile weights preserve the reference Mixture formula.""" + + weights = gaussian_tile_weights( + 4, + 4, + device=torch.device("cpu"), + dtype=torch.float32, + ) + expected = _reference_gaussian_weights(4, 4) + + assert weights.shape == (4, 4) + assert torch.all(weights > 0) + assert torch.allclose(weights, expected, rtol=1e-6, atol=1e-6) + + +@pytest.mark.parametrize( + ("latent_width", "latent_height", "tile_width", "tile_height", "tile_batch_size"), + [ + (0, 8, 4, 4, 1), + (8, 0, 4, 4, 1), + (8, 8, 3, 4, 1), + (8, 8, 4, 3, 1), + (8, 8, 4, 4, 0), + ], +) +def test_plan_rejects_invalid_dimensions( + latent_width: int, + latent_height: int, + tile_width: int, + tile_height: int, + tile_batch_size: int, +) -> None: + """Invalid tile controls fail before sampling side effects.""" + + with pytest.raises(ValueError): + build_tiled_diffusion_plan( + latent_width=latent_width, + latent_height=latent_height, + tile_width=tile_width, + tile_height=tile_height, + overlap=0, + tile_batch_size=tile_batch_size, + ) + + +def _reference_gaussian_weights(tile_width: int, tile_height: int) -> torch.Tensor: + """Return the NumPy-style Gaussian weight result using torch math.""" + + def f(value: int, midpoint: float, var: float = 0.01) -> float: + return math.exp( + -((value - midpoint) * (value - midpoint)) + / (tile_width * tile_width) + / (2 * var) + ) / math.sqrt(2 * math.pi * var) + + x_probs = [f(x, (tile_width - 1) / 2) for x in range(tile_width)] + y_probs = [f(y, tile_height / 2) for y in range(tile_height)] + return torch.tensor( + [[y_prob * x_prob for x_prob in x_probs] for y_prob in y_probs], + dtype=torch.float32, + ) diff --git a/tests/test_tiled_diffusion_sampling_service.py b/tests/test_tiled_diffusion_sampling_service.py new file mode 100644 index 0000000..d492b7d --- /dev/null +++ b/tests/test_tiled_diffusion_sampling_service.py @@ -0,0 +1,184 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for shared tiled diffusion sampling mode dispatch.""" + +from __future__ import annotations + +from typing import Any + +import pytest +import torch + +from simple_syrup.services.tiled_diffusion_sampling_service import ( + TiledDiffusionSamplingService, +) + + +def test_multidiffusion_mode_routes_to_multidiffusion_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """MultiDiffusion mode calls only the MultiDiffusion runtime.""" + + calls: dict[str, dict[str, Any]] = {} + output: dict[str, Any] = {"samples": torch.ones((1, 4, 4, 4))} + + def fake_multidiffusion(**kwargs: Any) -> dict[str, Any]: + """Record MultiDiffusion runtime arguments.""" + + calls["multidiffusion"] = kwargs + return output + + def fake_mixture(**kwargs: Any) -> dict[str, Any]: + """Fail if Mixture runtime is selected.""" + + calls["mixture"] = kwargs + raise AssertionError("Mixture runtime should not be called.") + + monkeypatch.setattr( + "simple_syrup.services.tiled_diffusion_sampling_service." + "multidiffusion_sampling.sample_multidiffusion", + fake_multidiffusion, + ) + monkeypatch.setattr( + "simple_syrup.services.tiled_diffusion_sampling_service." + "mixture_of_diffusers_sampling.sample_mixture_of_diffusers", + fake_mixture, + ) + + result = TiledDiffusionSamplingService().sample( + **_sample_kwargs(diffusion_mode="multidiffusion") + ) + + assert result is output + assert "multidiffusion" in calls + assert "mixture" not in calls + + +def test_mixture_mode_routes_to_mixture_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Mixture of Diffusers mode calls only the Mixture runtime.""" + + calls: dict[str, dict[str, Any]] = {} + output: dict[str, Any] = {"samples": torch.ones((1, 4, 4, 4))} + + def fake_multidiffusion(**kwargs: Any) -> dict[str, Any]: + """Fail if MultiDiffusion runtime is selected.""" + + calls["multidiffusion"] = kwargs + raise AssertionError("MultiDiffusion runtime should not be called.") + + def fake_mixture(**kwargs: Any) -> dict[str, Any]: + """Record Mixture runtime arguments.""" + + calls["mixture"] = kwargs + return output + + monkeypatch.setattr( + "simple_syrup.services.tiled_diffusion_sampling_service." + "multidiffusion_sampling.sample_multidiffusion", + fake_multidiffusion, + ) + monkeypatch.setattr( + "simple_syrup.services.tiled_diffusion_sampling_service." + "mixture_of_diffusers_sampling.sample_mixture_of_diffusers", + fake_mixture, + ) + + result = TiledDiffusionSamplingService().sample( + **_sample_kwargs(diffusion_mode="mixture_of_diffusers") + ) + + assert result is output + assert "mixture" in calls + assert "multidiffusion" not in calls + + +def test_service_forwards_sampling_arguments_unchanged( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The dispatcher preserves every sampler argument at the runtime boundary.""" + + calls: dict[str, Any] = {} + output: dict[str, Any] = {"samples": torch.ones((1, 4, 4, 4))} + preview_context = object() + + def fake_multidiffusion(**kwargs: Any) -> dict[str, Any]: + """Record forwarded arguments.""" + + calls.update(kwargs) + return output + + monkeypatch.setattr( + "simple_syrup.services.tiled_diffusion_sampling_service." + "multidiffusion_sampling.sample_multidiffusion", + fake_multidiffusion, + ) + + kwargs = _sample_kwargs( + diffusion_mode="multidiffusion", + preview_context=preview_context, + ) + result = TiledDiffusionSamplingService().sample(**kwargs) + + assert result is output + assert calls == { + key: value for key, value in kwargs.items() if key != "diffusion_mode" + } + + +def test_invalid_mode_fails_before_runtime_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unsupported modes are rejected before any runtime sampler is called.""" + + def fail_runtime(**kwargs: Any) -> dict[str, Any]: + """Fail if validation does not stop dispatch.""" + + del kwargs + raise AssertionError("Runtime should not be called.") + + monkeypatch.setattr( + "simple_syrup.services.tiled_diffusion_sampling_service." + "multidiffusion_sampling.sample_multidiffusion", + fail_runtime, + ) + monkeypatch.setattr( + "simple_syrup.services.tiled_diffusion_sampling_service." + "mixture_of_diffusers_sampling.sample_mixture_of_diffusers", + fail_runtime, + ) + + with pytest.raises(ValueError, match="diffusion_mode"): + TiledDiffusionSamplingService().sample( + **_sample_kwargs(diffusion_mode="full_latent") + ) + + +def _sample_kwargs( + *, + diffusion_mode: str, + preview_context: object | None = None, +) -> dict[str, Any]: + """Return valid tiled diffusion sample arguments.""" + + return { + "diffusion_mode": diffusion_mode, + "model": "model", + "seed": 123, + "steps": 20, + "cfg": 7.0, + "sampler_name": "euler", + "scheduler": "normal", + "positive": "positive", + "negative": "negative", + "latent_image": {"samples": torch.zeros((1, 4, 4, 4))}, + "denoise": 0.8, + "latent_tile_width": 128, + "latent_tile_height": 80, + "latent_tile_overlap": 24, + "latent_tile_batch_size": 3, + "preview_context": preview_context, + } diff --git a/tests/test_tiled_sampling_runtime.py b/tests/test_tiled_sampling_runtime.py new file mode 100644 index 0000000..c71082d --- /dev/null +++ b/tests/test_tiled_sampling_runtime.py @@ -0,0 +1,192 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for shared tiled sampling runtime helpers.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.domain.tiled_diffusion import LatentTile, build_tiled_diffusion_plan +from simple_syrup.runtime import tiled_sampling + + +def test_validate_latent_samples_accepts_bchw() -> None: + """Standard BCHW latents are valid for tiled samplers.""" + + samples = torch.zeros((1, 4, 8, 8)) + + assert ( + tiled_sampling.validate_latent_samples( + {"samples": samples}, + sampler_label="TestSampler", + ) + is samples + ) + + +def test_validate_latent_samples_accepts_singleton_depth_bcdhw() -> None: + """Singleton-depth BCDHW latents are valid for Anima-style models.""" + + samples = torch.zeros((1, 16, 1, 8, 8)) + + assert ( + tiled_sampling.validate_latent_samples( + {"samples": samples}, + sampler_label="TestSampler", + ) + is samples + ) + + +def test_validate_latent_samples_rejects_non_tensor() -> None: + """Latent dictionaries must contain tensor samples.""" + + with pytest.raises(ValueError, match="latent samples must be a torch tensor"): + tiled_sampling.validate_latent_samples( + {"samples": "not-a-tensor"}, + sampler_label="TestSampler", + ) + + +def test_validate_tensor_shape_rejects_nested_tensor() -> None: + """Nested tensors are rejected before spatial tiling.""" + + samples = torch.nested.nested_tensor([torch.zeros((4, 8, 8))]) + + with pytest.raises(ValueError, match="non-nested latent samples"): + tiled_sampling.validate_tensor_shape(samples, sampler_label="TestSampler") + + +def test_validate_tensor_shape_rejects_non_singleton_depth_5d() -> None: + """Non-singleton depth 5D latents remain unsupported.""" + + with pytest.raises(ValueError, match="singleton third axis"): + tiled_sampling.validate_tensor_shape( + torch.zeros((1, 16, 2, 8, 8)), + sampler_label="TestSampler", + ) + + +def test_spatial_tile_slicer_crops_final_axes_for_4d() -> None: + """Spatial slicers crop height and width for BCHW tensors.""" + + tensor = torch.arange(1 * 1 * 4 * 6).reshape((1, 1, 4, 6)) + tile = LatentTile(x=2, y=1, width=3, height=2) + + cropped = tensor[tiled_sampling.spatial_tile_slicer(tile, tensor.ndim)] + + assert torch.equal(cropped, tensor[:, :, 1:3, 2:5]) + + +def test_spatial_tile_slicer_crops_final_axes_for_5d() -> None: + """Spatial slicers preserve singleton depth while cropping BCDHW tensors.""" + + tensor = torch.arange(1 * 2 * 1 * 4 * 6).reshape((1, 2, 1, 4, 6)) + tile = LatentTile(x=1, y=2, width=4, height=2) + + cropped = tensor[tiled_sampling.spatial_tile_slicer(tile, tensor.ndim)] + + assert torch.equal(cropped, tensor[:, :, :, 2:4, 1:5]) + + +def test_tile_tensor_crops_spatial_tensor_per_tile() -> None: + """Spatial conditioning tensors are cropped and concatenated per tile.""" + + tensor = torch.arange(2 * 1 * 4 * 8, dtype=torch.float32).reshape((2, 1, 4, 8)) + tiles = (LatentTile(0, 0, 4, 4), LatentTile(4, 0, 4, 4)) + + tiled = tiled_sampling.tile_tensor( + tensor, + tiles=tiles, + input_batch_size=2, + latent_height=4, + latent_width=8, + ) + + assert tiled.shape == (4, 1, 4, 4) + assert torch.equal(tiled[:2], tensor[:, :, :, :4]) + assert torch.equal(tiled[2:], tensor[:, :, :, 4:]) + + +def test_tile_tensor_repeats_matching_batch_tensor() -> None: + """Batch-aligned non-spatial tensors repeat once per tile.""" + + tensor = torch.tensor([[1.0], [2.0]]) + tiles = (LatentTile(0, 0, 4, 4), LatentTile(4, 0, 4, 4)) + + tiled = tiled_sampling.tile_tensor( + tensor, + tiles=tiles, + input_batch_size=2, + latent_height=4, + latent_width=8, + ) + + assert torch.equal(tiled, torch.tensor([[1.0], [2.0], [1.0], [2.0]])) + + +def test_tile_tensor_repeats_singleton_batch_to_tiled_batch_size() -> None: + """Singleton-batch tensors expand to the full tiled input batch size.""" + + tensor = torch.tensor([[5.0, 6.0]]) + tiles = (LatentTile(0, 0, 4, 4), LatentTile(4, 0, 4, 4)) + + tiled = tiled_sampling.tile_tensor( + tensor, + tiles=tiles, + input_batch_size=2, + latent_height=4, + latent_width=8, + ) + + assert tiled.shape == (4, 2) + assert torch.equal(tiled, tensor.repeat((4, 1))) + + +def test_tile_transformer_options_repeats_model_metadata() -> None: + """Transformer metadata aligned to model batches repeats per tile.""" + + timestep = torch.tensor([0.5, 0.75, 0.5, 0.75]) + options = { + "cond_or_uncond": [0, 1], + "uuids": ("positive", "negative"), + "sigmas": torch.tensor([1.0, 0.0]), + "sample_sigmas": torch.tensor([1.0, 0.0]), + } + + tiled = tiled_sampling.tile_transformer_options( + options, + tile_count=2, + tiled_timestep=timestep, + ) + + assert tiled["cond_or_uncond"] == [0, 1, 0, 1] + assert tiled["uuids"] == ("positive", "negative", "positive", "negative") + assert torch.equal(tiled["sigmas"], timestep) + assert torch.equal(tiled["sample_sigmas"], torch.tensor([1.0, 0.0])) + + +def test_new_spatial_weight_buffer_broadcasts_over_spatial_axes() -> None: + """Spatial weight buffers broadcast over BCHW and BCDHW model outputs.""" + + plan = build_tiled_diffusion_plan(8, 4, 4, 4, 0, 1) + + assert tiled_sampling.new_spatial_weight_buffer( + torch.zeros((2, 4, 4, 8)), + plan, + ).shape == (1, 1, 4, 8) + assert tiled_sampling.new_spatial_weight_buffer( + torch.zeros((2, 16, 1, 4, 8)), + plan, + ).shape == (1, 1, 1, 4, 8) + + +def test_contains_unsupported_conditioning_key_finds_nested_values() -> None: + """Unsupported regional and control keys are detected recursively.""" + + conditioning = [{"model_conds": {"nested": [{"mask": torch.ones((1, 1))}]}}] + + assert tiled_sampling.contains_unsupported_conditioning_key(conditioning) diff --git a/tests/test_ultralytics_detection_service.py b/tests/test_ultralytics_detection_service.py new file mode 100644 index 0000000..bf4085c --- /dev/null +++ b/tests/test_ultralytics_detection_service.py @@ -0,0 +1,407 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for converting Ultralytics detections into SEGS.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.runtime.ultralytics_detection import UltralyticsDetection +from simple_syrup.runtime.ultralytics_loader import UltralyticsDetectorModel +from simple_syrup.services.segs_detection_service import ( + DetectionRunner, + SegsDetectionService, +) +from simple_syrup.services.segs_output_service import build_combined_segs_result + + +def test_bbox_prediction_creates_rectangular_mask_segs() -> None: + """BBox detections produce rectangular cropped masks.""" + + service = SegsDetectionService(detection_runner=_runner(_bbox_detection())) + + header, segments = service.detect(_image(), _model(False), 0.5, 0, 1.0, 1) + + assert header == (8, 8) + assert len(segments) == 1 + assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 9.0 + assert segments[0].crop_region == (2, 2, 5, 5) + + +def test_segmentation_prediction_uses_mask_shape() -> None: + """Segmentation detections preserve the predicted mask values.""" + + mask = torch.zeros((8, 8), dtype=torch.float32) + mask[1:6, 1:6] = 1.0 + detection = UltralyticsDetection(BoundingBox(2, 2, 5, 5), 0.9, "face", mask) + service = SegsDetectionService(detection_runner=_runner(detection)) + + _header, segments = service.detect(_image(), _model(True), 0.5, 0, 1.0, 1) + + assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 9.0 + + +def test_threshold_filters_low_confidence_detections() -> None: + """Low-confidence detections are dropped.""" + + detection = UltralyticsDetection(BoundingBox(2, 2, 5, 5), 0.25, "face", None) + service = SegsDetectionService(detection_runner=_runner(detection)) + + _header, segments = service.detect(_image(), _model(False), 0.5, 0, 1.0, 1) + + assert segments == () + + +def test_drop_size_filters_tiny_detections() -> None: + """Detections smaller than drop_size are dropped.""" + + detection = UltralyticsDetection(BoundingBox(2, 2, 4, 4), 0.9, "face", None) + service = SegsDetectionService(detection_runner=_runner(detection)) + + _header, segments = service.detect(_image(), _model(False), 0.5, 0, 1.0, 3) + + assert segments == () + + +def test_crop_factor_expands_and_clamps_region() -> None: + """Crop factor expands around the bbox without leaving image bounds.""" + + service = SegsDetectionService(detection_runner=_runner(_bbox_detection())) + + _header, segments = service.detect(_image(), _model(False), 0.5, 0, 3.0, 1) + + assert segments[0].crop_region == (0, 0, 8, 8) + + +def test_crop_factor_zero_uses_full_image_region() -> None: + """A zero crop factor selects the full image as the SEG crop.""" + + service = SegsDetectionService(detection_runner=_runner(_bbox_detection())) + + _header, segments = service.detect(_image(), _model(False), 0.5, 0, 0.0, 1) + + assert segments[0].crop_region == (0, 0, 8, 8) + assert cast(torch.Tensor, segments[0].cropped_image).shape == (1, 8, 8, 3) + assert cast(torch.Tensor, segments[0].cropped_mask).shape == (8, 8) + + +def test_fractional_crop_factor_below_one_still_fails() -> None: + """Crop factor accepts zero or values at least one, but not in between.""" + + service = SegsDetectionService(detection_runner=_runner(_bbox_detection())) + + with pytest.raises(ValueError, match="crop_factor"): + service.detect(_image(), _model(False), 0.5, 0, 0.5, 1) + + +def test_dilation_changes_mask_extent() -> None: + """Dilation expands rectangular mask coverage.""" + + service = SegsDetectionService(detection_runner=_runner(_bbox_detection())) + + _header, segments = service.detect(_image(), _model(False), 0.5, 1, 1.0, 1) + + assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 9.0 + + +def test_negative_dilation_erodes_mask_extent() -> None: + """Negative dilation erodes the detected mask.""" + + detection = UltralyticsDetection(BoundingBox(1, 1, 7, 7), 0.9, "face", None) + service = SegsDetectionService(detection_runner=_runner(detection)) + + _header, segments = service.detect(_image(), _model(False), 0.5, -1, 1.0, 1) + + assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 16.0 + + +def test_labels_filter_segments() -> None: + """Comma-separated labels keep only matching segments.""" + + face = UltralyticsDetection(BoundingBox(1, 1, 4, 4), 0.9, "face", None) + hand = UltralyticsDetection(BoundingBox(4, 4, 7, 7), 0.9, "hand", None) + service = SegsDetectionService(detection_runner=_runner(face, hand)) + + _header, segments = service.detect( + _image(), + _model(False), + 0.5, + 0, + 1.0, + 1, + labels="face", + ) + + assert [segment.label for segment in segments] == ["face"] + + +def test_label_groups_match_impact_aliases() -> None: + """Impact-style label groups are supported without importing Impact.""" + + eye = UltralyticsDetection(BoundingBox(1, 1, 4, 4), 0.9, "left_eye", None) + hand = UltralyticsDetection(BoundingBox(4, 4, 7, 7), 0.9, "hand", None) + service = SegsDetectionService(detection_runner=_runner(eye, hand)) + + _header, segments = service.detect( + _image(), + _model(False), + 0.5, + 0, + 1.0, + 1, + labels="eyes", + ) + + assert [segment.label for segment in segments] == ["left_eye"] + + +def test_post_dilation_changes_cropped_mask_after_crop() -> None: + """Post-dilation applies to the cropped SEGS mask.""" + + detection = UltralyticsDetection(BoundingBox(2, 2, 5, 5), 0.9, "face", None) + service = SegsDetectionService(detection_runner=_runner(detection)) + + _header, segments = service.detect( + _image(), + _model(False), + 0.5, + 0, + 3.0, + 1, + post_dilation=-1, + ) + + assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 1.0 + + +def test_simple_detector_refines_bbox_segs_with_segmentation_mask() -> None: + """Simple detection intersects bbox SEGS with segmentation sub-detections.""" + + segmentation_mask = torch.zeros((8, 8), dtype=torch.float32) + segmentation_mask[3:5, 3:5] = 1.0 + runner = _SequentialRunner( + (UltralyticsDetection(BoundingBox(2, 2, 6, 6), 0.9, "face", None),), + ( + UltralyticsDetection( + BoundingBox(2, 2, 6, 6), + 0.8, + "face", + segmentation_mask, + ), + ), + ) + service = SegsDetectionService(detection_runner=runner) + + _header, segments = service.detect_simple( + _image(), + _model(True), + bbox_threshold=0.5, + bbox_dilation=0, + crop_factor=1.0, + drop_size=1, + sub_threshold=0.6, + sub_dilation=0, + ) + + assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 4.0 + assert runner.calls == [(0.5, False), (0.6, True)] + + +def test_simple_detector_bbox_only_model_skips_segmentation_refinement() -> None: + """BBox-only models use the bbox path without a second sub-detection call.""" + + runner = _SequentialRunner( + (UltralyticsDetection(BoundingBox(2, 2, 5, 5), 0.9, "face", None),) + ) + service = SegsDetectionService(detection_runner=runner) + + _header, segments = service.detect_simple( + _image(), + _model(False), + bbox_threshold=0.5, + bbox_dilation=0, + crop_factor=1.0, + drop_size=1, + sub_threshold=0.6, + sub_dilation=0, + ) + + assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 9.0 + assert runner.calls == [(0.5, False)] + + +def test_empty_detections_return_empty_segs() -> None: + """No detections returns an empty SEGS payload.""" + + service = SegsDetectionService(detection_runner=_runner()) + + assert service.detect(_image(), _model(False), 0.5, 0, 1.0, 1) == ((8, 8), ()) + + +def test_empty_segs_build_empty_combined_outputs() -> None: + """Empty SEGS produce empty combined SEGS and a zero mask.""" + + result = build_combined_segs_result(_image(), ((8, 8), ())) + + assert result.segs == ((8, 8), ()) + assert result.mask.shape == (1, 8, 8) + assert result.mask.dtype == torch.float32 + assert result.mask.sum().item() == 0.0 + + +def test_combined_result_unions_all_segment_masks() -> None: + """Combined SEGS contain one tight segment for all source masks.""" + + image = torch.arange(1 * 8 * 8 * 3, dtype=torch.float32).reshape((1, 8, 8, 3)) + image = image / image.max() + first = _segment(CropRegion(1, 1, 3, 3), 0.4, torch.ones((2, 2))) + second = _segment( + CropRegion(4, 5, 7, 7), + 0.9, + [[2.0, 2.0, 2.0], [2.0, 2.0, 2.0]], + ) + + result = build_combined_segs_result(image, _segs(first, second)) + _header, segments = result.segs + + assert result.mask.shape == (1, 8, 8) + assert result.mask.sum().item() == 10.0 + assert len(segments) == 1 + assert segments[0].crop_region == CropRegion(1, 1, 7, 7) + assert segments[0].bbox == BoundingBox(1, 1, 7, 7) + assert segments[0].label == "combined" + assert segments[0].confidence == 0.9 + assert cast(torch.Tensor, segments[0].cropped_mask).shape == (6, 6) + assert torch.equal( + cast(torch.Tensor, segments[0].cropped_image), image[:, 1:7, 1:7, :] + ) + + +def test_combined_mask_uses_max_for_overlaps() -> None: + """Overlapping source masks combine by max instead of addition.""" + + first = _segment(CropRegion(2, 2, 4, 4), 0.4, torch.full((2, 2), 0.7)) + second = _segment(CropRegion(2, 2, 4, 4), 0.8, torch.full((2, 2), 0.8)) + + result = build_combined_segs_result(_image(), _segs(first, second)) + + assert result.mask[0, 2, 2].item() == pytest.approx(0.8) + assert result.mask.sum().item() == pytest.approx(3.2) + + +def test_combined_outputs_reject_mismatched_cropped_mask_shape() -> None: + """Invalid crop-local masks fail before producing misleading outputs.""" + + segment = _segment(CropRegion(1, 1, 3, 3), 1.0, torch.ones((3, 3))) + + with pytest.raises(ValueError, match="cropped_mask must match"): + build_combined_segs_result(_image(), _segs(segment)) + + +def test_batch_image_input_fails_clearly() -> None: + """The first version rejects batched images.""" + + service = SegsDetectionService(detection_runner=_runner()) + + with pytest.raises(ValueError, match="supports one image at a time"): + service.detect(torch.zeros((2, 8, 8, 3)), _model(False), 0.5, 0, 1.0, 1) + + +def _bbox_detection() -> UltralyticsDetection: + """Return a reusable bbox detection.""" + + return UltralyticsDetection(BoundingBox(2, 2, 5, 5), 0.9, "face", None) + + +def _segs(*segments: Segment) -> NativeSegs: + """Return native SEGS for an 8x8 image.""" + + return (8, 8), tuple(segments) + + +def _segment( + crop_region: CropRegion, + confidence: float, + cropped_mask: object, +) -> Segment: + """Return one segment for combined-output tests.""" + + return Segment( + cropped_image=None, + cropped_mask=cropped_mask, + confidence=confidence, + crop_region=crop_region, + bbox=BoundingBox( + crop_region.left, + crop_region.top, + crop_region.right, + crop_region.bottom, + ), + label="face", + ) + + +def _runner( + *detections: UltralyticsDetection, +) -> DetectionRunner: + """Return a detection runner fake.""" + + def run( + detector_model: UltralyticsDetectorModel, + image: torch.Tensor, + threshold: float, + prefer_segmentation: bool, + ) -> tuple[UltralyticsDetection, ...]: + del detector_model, image, threshold, prefer_segmentation + return tuple(detections) + + return run + + +class _SequentialRunner: + """Detection runner fake that returns a different payload per call.""" + + def __init__(self, *responses: tuple[UltralyticsDetection, ...]) -> None: + """Store ordered fake detection responses.""" + + self._responses = list(responses) + self.calls: list[tuple[float, bool]] = [] + + def __call__( + self, + detector_model: UltralyticsDetectorModel, + image: torch.Tensor, + threshold: float, + prefer_segmentation: bool, + ) -> tuple[UltralyticsDetection, ...]: + """Return the next response and record call parameters.""" + + del detector_model, image + self.calls.append((threshold, prefer_segmentation)) + return self._responses.pop(0) + + +def _image() -> torch.Tensor: + """Return a small single-image tensor.""" + + return torch.zeros((1, 8, 8, 3), dtype=torch.float32) + + +def _model(supports_segmentation: bool) -> UltralyticsDetectorModel: + """Return a native detector model test double.""" + + return UltralyticsDetectorModel( + model_name="model.pt", + model_path=Path("model.pt"), + model=object(), + task="segment" if supports_segmentation else "detect", + names={0: "face"}, + supports_segmentation=supports_segmentation, + ) diff --git a/tests/test_ultralytics_loader.py b/tests/test_ultralytics_loader.py new file mode 100644 index 0000000..dd14b4d --- /dev/null +++ b/tests/test_ultralytics_loader.py @@ -0,0 +1,261 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Ultralytics detector model loading.""" + +from __future__ import annotations + +import importlib +from pathlib import Path +from types import ModuleType +from typing import Any, cast + +import pytest + +from simple_syrup.runtime.ultralytics_loader import ( + NO_LOCAL_ULTRALYTICS_MODELS, + LoadedUltralyticsDetector, + UltralyticsLoaderService, + UltralyticsModelCacheKey, +) + + +def test_model_choices_list_conventional_folders(tmp_path: Path) -> None: + """Model choices include root, bbox, and segmentation conventions.""" + + models_dir = tmp_path / "models" + (models_dir / "ultralytics").mkdir(parents=True) + (models_dir / "ultralytics" / "bbox").mkdir() + (models_dir / "ultralytics" / "segm").mkdir() + (models_dir / "ultralytics" / "root.pt").write_bytes(b"") + (models_dir / "ultralytics" / "bbox" / "face.pt").write_bytes(b"") + (models_dir / "ultralytics" / "segm" / "person.pt").write_bytes(b"") + + service = UltralyticsLoaderService(folder_paths_module=_folder_paths(models_dir)) + + assert service.model_choices() == ["bbox/face.pt", "root.pt", "segm/person.pt"] + + +def test_model_choices_returns_sentinel_when_no_models(tmp_path: Path) -> None: + """An empty model directory returns a clear dropdown sentinel.""" + + models_dir = tmp_path / "models" + models_dir.mkdir() + + service = UltralyticsLoaderService(folder_paths_module=_folder_paths(models_dir)) + + assert service.model_choices() == [NO_LOCAL_ULTRALYTICS_MODELS] + + +def test_missing_model_raises_value_error(tmp_path: Path) -> None: + """Loading rejects unknown model choices before importing Ultralytics.""" + + service = UltralyticsLoaderService(folder_paths_module=_folder_paths(tmp_path)) + + with pytest.raises(ValueError, match="was not found"): + service.resolve_model_path("bbox/missing.pt") + + +def test_missing_ultralytics_import_raises_actionable_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The optional Ultralytics dependency fails with install guidance.""" + + models_dir = tmp_path / "models" + (models_dir / "ultralytics").mkdir(parents=True) + (models_dir / "ultralytics" / "model.pt").write_bytes(b"") + + real_import = importlib.import_module + + def fake_import(name: str, package: str | None = None) -> ModuleType: + if name == "ultralytics": + raise ModuleNotFoundError(name) + return real_import(name, package) + + monkeypatch.setattr(importlib, "import_module", fake_import) + service = UltralyticsLoaderService(folder_paths_module=_folder_paths(models_dir)) + + with pytest.raises(RuntimeError, match="requires the 'ultralytics' package"): + service.load("model.pt") + + +def test_loader_returns_native_and_compatibility_outputs(tmp_path: Path) -> None: + """Loading returns one native model and paired detector facades.""" + + models_dir = tmp_path / "models" + (models_dir / "ultralytics" / "segm").mkdir(parents=True) + (models_dir / "ultralytics" / "segm" / "face.pt").write_bytes(b"") + ultralytics_module = ModuleType("ultralytics") + cast(Any, ultralytics_module).YOLO = _FakeYOLO + + service = UltralyticsLoaderService( + folder_paths_module=_folder_paths(models_dir), + ultralytics_module=ultralytics_module, + ) + + loaded = service.load("segm/face.pt") + + assert loaded.detector_model.model_name == "segm/face.pt" + assert loaded.detector_model.supports_segmentation is True + assert loaded.bbox_detector is cast(Any, loaded.segm_detector).bbox_detector + + +def test_bbox_prefix_marks_model_as_bbox_only(tmp_path: Path) -> None: + """BBox-prefixed models do not claim segmentation support.""" + + models_dir = tmp_path / "models" + (models_dir / "ultralytics" / "bbox").mkdir(parents=True) + (models_dir / "ultralytics" / "bbox" / "face.pt").write_bytes(b"") + ultralytics_module = ModuleType("ultralytics") + cast(Any, ultralytics_module).YOLO = _FakeYOLO + + service = UltralyticsLoaderService( + folder_paths_module=_folder_paths(models_dir), + ultralytics_module=ultralytics_module, + ) + + loaded = service.load("bbox/face.pt") + + assert loaded.detector_model.supports_segmentation is False + + +def test_loader_uses_process_cache_for_identical_selection(tmp_path: Path) -> None: + """Identical Ultralytics selections reuse the same loaded detector bundle.""" + + models_dir = tmp_path / "models" + (models_dir / "ultralytics").mkdir(parents=True) + (models_dir / "ultralytics" / "model.pt").write_bytes(b"") + ultralytics_module = ModuleType("ultralytics") + yolo_factory = _RecordingYOLOFactory() + cast(Any, ultralytics_module).YOLO = yolo_factory + cache: dict[UltralyticsModelCacheKey, LoadedUltralyticsDetector] = {} + service = UltralyticsLoaderService( + folder_paths_module=_folder_paths(models_dir), + ultralytics_module=ultralytics_module, + cache=cache, + ) + + first = service.load("model.pt") + second = service.load("model.pt") + + assert second is first + assert yolo_factory.paths == [str(models_dir / "ultralytics" / "model.pt")] + assert len(cache) == 1 + + +def test_loader_cache_separates_prefixed_selections(tmp_path: Path) -> None: + """BBox and segmentation selections stay separate cache entries.""" + + models_dir = tmp_path / "models" + (models_dir / "ultralytics" / "bbox").mkdir(parents=True) + (models_dir / "ultralytics" / "segm").mkdir(parents=True) + (models_dir / "ultralytics" / "bbox" / "face.pt").write_bytes(b"") + (models_dir / "ultralytics" / "segm" / "face.pt").write_bytes(b"") + ultralytics_module = ModuleType("ultralytics") + yolo_factory = _RecordingYOLOFactory() + cast(Any, ultralytics_module).YOLO = yolo_factory + cache: dict[UltralyticsModelCacheKey, LoadedUltralyticsDetector] = {} + service = UltralyticsLoaderService( + folder_paths_module=_folder_paths(models_dir), + ultralytics_module=ultralytics_module, + cache=cache, + ) + + first = service.load("bbox/face.pt") + second = service.load("segm/face.pt") + + assert second is not first + assert first.detector_model.supports_segmentation is False + assert second.detector_model.supports_segmentation is True + assert yolo_factory.paths == [ + str(models_dir / "ultralytics" / "bbox" / "face.pt"), + str(models_dir / "ultralytics" / "segm" / "face.pt"), + ] + assert len(cache) == 2 + + +def test_loader_does_not_cache_failed_yolo_construction(tmp_path: Path) -> None: + """A failed YOLO construction leaves the cache empty for retry.""" + + models_dir = tmp_path / "models" + (models_dir / "ultralytics").mkdir(parents=True) + (models_dir / "ultralytics" / "model.pt").write_bytes(b"") + ultralytics_module = ModuleType("ultralytics") + yolo_factory = _RecordingYOLOFactory(fail_once=True) + cast(Any, ultralytics_module).YOLO = yolo_factory + cache: dict[UltralyticsModelCacheKey, LoadedUltralyticsDetector] = {} + service = UltralyticsLoaderService( + folder_paths_module=_folder_paths(models_dir), + ultralytics_module=ultralytics_module, + cache=cache, + ) + + with pytest.raises(RuntimeError, match="could not be loaded"): + service.load("model.pt") + + loaded = service.load("model.pt") + + assert isinstance(loaded, LoadedUltralyticsDetector) + assert len(yolo_factory.paths) == 2 + assert len(cache) == 1 + + +class _FakeYOLO: + """Small fake for Ultralytics YOLO construction.""" + + task = "detect" + names = {0: "face"} + + def __init__(self, path: str) -> None: + """Record the model path used for loading.""" + + self.path = path + + +class _RecordingYOLOFactory: + """Callable fake YOLO constructor with call recording.""" + + def __init__(self, fail_once: bool = False) -> None: + """Create a recording YOLO factory.""" + + self.paths: list[str] = [] + self.fail_once = fail_once + + def __call__(self, path: str) -> _FakeYOLO: + """Record the requested model path and optionally fail once.""" + + self.paths.append(path) + if self.fail_once: + self.fail_once = False + raise RuntimeError("YOLO failed") + return _FakeYOLO(path) + + +def _folder_paths(models_dir: Path) -> ModuleType: + """Build a minimal fake ComfyUI folder_paths module.""" + + module = ModuleType("folder_paths") + module_any = cast(Any, module) + module_any.models_dir = str(models_dir) + module_any.folder_names_and_paths = {} + + def add_model_folder_path(folder_name: str, path: str) -> None: + module_any.folder_names_and_paths[folder_name] = ( + [path], + {".pt", ".pth", ".safetensors"}, + ) + + def get_filename_list(folder_name: str) -> list[str]: + paths = module_any.folder_names_and_paths.get(folder_name, ([], set()))[0] + names: list[str] = [] + for folder in paths: + root = Path(str(folder)) + if root.is_dir(): + names.extend(path.name for path in root.iterdir() if path.is_file()) + return names + + module_any.add_model_folder_path = add_model_folder_path + module_any.get_filename_list = get_filename_list + return module diff --git a/tests/test_upscale_latent_from_image_node.py b/tests/test_upscale_latent_from_image_node.py new file mode 100644 index 0000000..75cbc45 --- /dev/null +++ b/tests/test_upscale_latent_from_image_node.py @@ -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 + +"""Tests for the Upscale Latent From Image node.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from simple_syrup.nodes.provenance_latent import ( + LATENT_PROVENANCE_ERROR, + UpscaleLatentFromImage, +) + + +def test_upscale_latent_from_image_declares_raw_link_image_input() -> None: + """Upscale Latent From Image exposes image plus latent scale controls.""" + + inputs = UpscaleLatentFromImage.INPUT_TYPES() + + assert UpscaleLatentFromImage.RETURN_TYPES == ("LATENT",) + assert UpscaleLatentFromImage.RETURN_NAMES == ("latent",) + assert inputs["required"]["image"][0] == "IMAGE" + assert inputs["required"]["image"][1]["rawLink"] is True + assert inputs["required"]["scale_factor"][0] == "FLOAT" + assert inputs["hidden"]["prompt"] == "PROMPT" + + +def test_upscale_method_choices_match_comfy_latent_upscale_by() -> None: + """The node mirrors Comfy's LatentUpscaleBy interpolation choices.""" + + methods = UpscaleLatentFromImage.INPUT_TYPES()["required"]["upscale_method"][0] + + assert methods == ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"] + + +def test_upscale_latent_from_image_expands_to_latent_upscale_by() -> None: + """Valid decode provenance emits a LatentUpscaleBy dynamic graph.""" + + result = UpscaleLatentFromImage().upscale( + ["decode", 0], + "bislerp", + 2.0, + { + "decode": { + "class_type": "VAEDecode", + "inputs": {"samples": ["latent", 0], "vae": ["loader", 2]}, + } + }, + ) + + node = _single_node(result["expand"]) + assert node["class_type"] == "LatentUpscaleBy" + assert node["inputs"] == { + "samples": ["latent", 0], + "upscale_method": "bislerp", + "scale_by": 2.0, + } + assert result["result"][0][0] in result["expand"] + assert result["result"][0][1] == 0 + + +def test_upscale_latent_from_image_fails_when_provenance_breaks() -> None: + """Latent upscale refuses to encode or upscale edited image pixels.""" + + with pytest.raises(ValueError, match="Unable to find an unmodified VAE Decode"): + UpscaleLatentFromImage().upscale( + ["edited", 0], + "bilinear", + 1.5, + { + "edited": { + "class_type": "ImageEdit", + "inputs": {"image": ["decode", 0]}, + } + }, + ) + + assert "VAE Decode" in LATENT_PROVENANCE_ERROR + + +def _single_node(graph: dict[str, dict[str, Any]]) -> dict[str, Any]: + """Return the only node from a dynamic expansion graph.""" + + assert len(graph) == 1 + return next(iter(graph.values())) diff --git a/tests/test_vae_loader.py b/tests/test_vae_loader.py new file mode 100644 index 0000000..71fae67 --- /dev/null +++ b/tests/test_vae_loader.py @@ -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 + +"""Tests for shared ComfyUI-compatible VAE loading.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path +from types import ModuleType + +import pytest + +from simple_syrup.runtime.vae_loader import VaeLoaderService, vae_choices + + +@dataclass +class FakeComfyState: + """Recorded calls into fake ComfyUI VAE APIs.""" + + loaded_paths: list[str] = field(default_factory=list) + vae_states: list[dict[str, object]] = field(default_factory=list) + vae_metadata: list[object | None] = field(default_factory=list) + validations: int = 0 + + +class FakeFolderPaths(ModuleType): + """Folder paths fake with VAE model methods.""" + + def __init__(self, models_dir: Path) -> None: + """Create fake VAE folder state.""" + + super().__init__("folder_paths") + self.models_dir = str(models_dir) + self.files: dict[str, list[str]] = { + "vae": ["manual_vae.safetensors"], + "vae_approx": [], + } + + def get_filename_list(self, folder_name: str) -> list[str]: + """Return configured relative filenames.""" + + return self.files.get(folder_name, []) + + def get_full_path_or_raise(self, folder_name: str, filename: str) -> str: + """Return a deterministic absolute path for a relative filename.""" + + return str(Path(self.models_dir) / folder_name / filename) + + +def test_vae_choices_match_comfy_special_choices(tmp_path: Path) -> None: + """VAE choices include ComfyUI TAESD, video TAE, and pixel-space entries.""" + + folder_paths = FakeFolderPaths(tmp_path / "models") + folder_paths.files["vae"] = ["manual_vae.safetensors"] + folder_paths.files["vae_approx"] = [ + "taesd_encoder.pth", + "taesd_decoder.pth", + "taehv.pth", + ] + + assert vae_choices(folder_paths) == [ + "manual_vae.safetensors", + "taehv.pth", + "taesd", + "pixel_space", + ] + + +def test_vae_loader_loads_pixel_space( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Pixel-space VAE selection builds and validates a ComfyUI VAE.""" + + state = _install_fake_comfy(monkeypatch) + service = VaeLoaderService(FakeFolderPaths(tmp_path / "models")) + + result = service.load_vae("pixel_space") + + assert result is not None + assert "pixel_space_vae" in state.vae_states[0] + assert state.vae_metadata == [None] + assert state.validations == 1 + + +def test_vae_loader_loads_manual_vae_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Manual VAE files load from the ComfyUI `vae` folder.""" + + state = _install_fake_comfy(monkeypatch) + folder_paths = FakeFolderPaths(tmp_path / "models") + service = VaeLoaderService(folder_paths) + + service.load_vae("manual_vae.safetensors") + + assert state.loaded_paths == [ + str(tmp_path / "models" / "vae" / "manual_vae.safetensors") + ] + assert state.vae_states[0]["loaded_from"] == state.loaded_paths[0] + assert state.vae_metadata[0] == {"metadata_from": state.loaded_paths[0]} + + +def test_vae_loader_loads_video_tae_from_approx_folder( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Video TAE files load from ComfyUI's `vae_approx` folder.""" + + state = _install_fake_comfy(monkeypatch) + folder_paths = FakeFolderPaths(tmp_path / "models") + folder_paths.files["vae_approx"] = ["taehv.pth"] + service = VaeLoaderService(folder_paths) + + service.load_vae("taehv.pth") + + assert state.loaded_paths == [str(tmp_path / "models" / "vae_approx" / "taehv.pth")] + + +def test_vae_loader_loads_taesd_encoder_decoder_pair( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """TAESD selections load matching encoder and decoder files.""" + + state = _install_fake_comfy(monkeypatch) + folder_paths = FakeFolderPaths(tmp_path / "models") + folder_paths.files["vae_approx"] = [ + "taesd_encoder.pth", + "taesd_decoder.pth", + ] + service = VaeLoaderService(folder_paths) + + service.load_vae("taesd") + + assert state.loaded_paths == [ + str(tmp_path / "models" / "vae_approx" / "taesd_encoder.pth"), + str(tmp_path / "models" / "vae_approx" / "taesd_decoder.pth"), + ] + assert state.vae_states[0]["taesd_encoder.loaded_from"] == state.loaded_paths[0] + assert state.vae_states[0]["taesd_decoder.loaded_from"] == state.loaded_paths[1] + assert "vae_scale" in state.vae_states[0] + assert "vae_shift" in state.vae_states[0] + + +def _install_fake_comfy(monkeypatch: pytest.MonkeyPatch) -> FakeComfyState: + """Install fake ComfyUI modules and return recorded VAE state.""" + + class FakeVAE: + """Small VAE fake that records state dicts and validation.""" + + def __init__( + self, + sd: dict[str, object], + metadata: object | None = None, + ) -> None: + """Record constructor inputs.""" + + state.vae_states.append(sd) + state.vae_metadata.append(metadata) + + def throw_exception_if_invalid(self) -> None: + """Accept validation and record that it happened.""" + + state.validations += 1 + + comfy_module = ModuleType("comfy") + comfy_sd = ModuleType("comfy.sd") + comfy_utils = ModuleType("comfy.utils") + state = FakeComfyState() + + def load_torch_file( + path: str, + return_metadata: bool = False, + ) -> dict[str, object] | tuple[dict[str, object], dict[str, object]]: + """Record VAE file loads and return fake torch state.""" + + state.loaded_paths.append(path) + sd: dict[str, object] = {"loaded_from": path} + if return_metadata: + return sd, {"metadata_from": path} + return sd + + comfy_sd.VAE = FakeVAE # type: ignore[attr-defined] + comfy_utils.load_torch_file = load_torch_file # type: ignore[attr-defined] + comfy_module.sd = comfy_sd # type: ignore[attr-defined] + comfy_module.utils = comfy_utils # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "comfy", comfy_module) + monkeypatch.setitem(sys.modules, "comfy.sd", comfy_sd) + monkeypatch.setitem(sys.modules, "comfy.utils", comfy_utils) + return state diff --git a/tests/test_vitmatte_loader.py b/tests/test_vitmatte_loader.py new file mode 100644 index 0000000..6fe3998 --- /dev/null +++ b/tests/test_vitmatte_loader.py @@ -0,0 +1,309 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for ViTMatte loader runtime service.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path +from types import ModuleType + +import pytest + +from simple_syrup.runtime.loaded_models import LoadedViTMatteModel +from simple_syrup.runtime.model_catalog import get_vitmatte_entry, vitmatte_choices +from simple_syrup.runtime.vitmatte_loader import ( + ViTMatteLoaderService, + ViTMatteModelCacheKey, + is_valid_vitmatte_directory, +) +from test_helpers import FakeFolderPaths + + +class RecordingSnapshotDownloader: + """Downloader double that writes a valid ViTMatte snapshot.""" + + def __init__(self) -> None: + """Create an empty downloader recorder.""" + + self.requests: list[tuple[str, Path]] = [] + + def download_snapshot( + self, + repo_id: str, + destination: Path, + progress: object | None = None, + ) -> Path: + """Record and satisfy a snapshot download request.""" + + self.requests.append((repo_id, destination)) + _write_vitmatte_snapshot(destination) + return destination + + +def test_vitmatte_choices_include_small_and_base() -> None: + """ViTMatte catalog exposes the intended model choices.""" + + assert vitmatte_choices() == [ + "vitmatte-small-composition-1k", + "vitmatte-base-composition-1k", + ] + + +def test_vitmatte_valid_directory_requires_expected_files(tmp_path: Path) -> None: + """ViTMatte directory validation is bounded to expected HF snapshot files.""" + + _write_vitmatte_snapshot(tmp_path) + + assert is_valid_vitmatte_directory(tmp_path) is True + + +def test_vitmatte_loader_prefers_canonical_path(tmp_path: Path) -> None: + """Canonical SimpleSyrup paths win over LayerStyle-compatible paths.""" + + canonical = tmp_path / "vitmatte" / "vitmatte-small-composition-1k" + layerstyle = tmp_path / "vitmatte" + _write_vitmatte_snapshot(canonical) + _write_vitmatte_snapshot(layerstyle) + + resolved = ViTMatteLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path) + ).resolve_model_directory( + get_vitmatte_entry("vitmatte-small-composition-1k"), + auto_download=False, + ) + + assert resolved.path == canonical + + +def test_vitmatte_loader_reuses_layerstyle_small_path(tmp_path: Path) -> None: + """LayerStyle's small-model path is reused when it is a valid snapshot.""" + + layerstyle = tmp_path / "vitmatte" + _write_vitmatte_snapshot(layerstyle) + + resolved = ViTMatteLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path) + ).resolve_model_directory( + get_vitmatte_entry("vitmatte-small-composition-1k"), + auto_download=False, + ) + + assert resolved.path == layerstyle + assert resolved.source == "layerstyle-compatible" + + +def test_vitmatte_loader_reuses_layerstyle_base_path(tmp_path: Path) -> None: + """LayerStyle's base-model path is reused when it is a valid snapshot.""" + + layerstyle = tmp_path / "vitmatte-base-composition-1k" + _write_vitmatte_snapshot(layerstyle) + + resolved = ViTMatteLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path) + ).resolve_model_directory( + get_vitmatte_entry("vitmatte-base-composition-1k"), + auto_download=False, + ) + + assert resolved.path == layerstyle + + +def test_vitmatte_loader_downloads_missing_model_to_canonical_path( + tmp_path: Path, +) -> None: + """Missing ViTMatte models download to SimpleSyrup's canonical layout.""" + + downloader = RecordingSnapshotDownloader() + resolved = ViTMatteLoaderService( + downloader=downloader, + folder_paths_module=FakeFolderPaths(tmp_path), + ).resolve_model_directory( + get_vitmatte_entry("vitmatte-small-composition-1k"), + auto_download=True, + ) + + assert resolved.path == tmp_path / "vitmatte" / "vitmatte-small-composition-1k" + assert downloader.requests == [ + ( + "hustvl/vitmatte-small-composition-1k", + tmp_path / "vitmatte" / "vitmatte-small-composition-1k", + ) + ] + + +def test_vitmatte_loader_fails_when_missing_and_download_disabled( + tmp_path: Path, +) -> None: + """Missing ViTMatte models fail clearly when auto-download is disabled.""" + + with pytest.raises(FileNotFoundError, match="auto_download is disabled"): + ViTMatteLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path) + ).resolve_model_directory( + get_vitmatte_entry("vitmatte-small-composition-1k"), + auto_download=False, + ) + + +def test_vitmatte_loader_loads_transformers_objects( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ViTMatte loader loads model and processor from a valid local directory.""" + + _write_vitmatte_snapshot(tmp_path / "vitmatte") + _install_fake_transformers(monkeypatch) + + loaded = ViTMatteLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path) + ).load_model("vitmatte-small-composition-1k", auto_download=False) + + assert isinstance(loaded, LoadedViTMatteModel) + assert loaded.model_id == "vitmatte-small-composition-1k" + assert loaded.managed_model is not None + + +def test_vitmatte_loader_uses_process_cache_for_identical_model( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Identical ViTMatte loads reuse the same model and processor container.""" + + _write_vitmatte_snapshot(tmp_path / "vitmatte") + state = _install_fake_transformers(monkeypatch) + cache: dict[ViTMatteModelCacheKey, LoadedViTMatteModel] = {} + service = ViTMatteLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache=cache, + ) + + first = service.load_model("vitmatte-small-composition-1k", auto_download=False) + second = service.load_model("vitmatte-small-composition-1k", auto_download=False) + + assert second is first + assert state.model_paths == [str(tmp_path / "vitmatte")] + assert state.processor_paths == [str(tmp_path / "vitmatte")] + assert len(cache) == 1 + + +def test_vitmatte_loader_cache_separates_model_paths( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Different ViTMatte model paths produce separate loaded containers.""" + + _write_vitmatte_snapshot(tmp_path / "vitmatte") + _write_vitmatte_snapshot(tmp_path / "vitmatte-base-composition-1k") + state = _install_fake_transformers(monkeypatch) + cache: dict[ViTMatteModelCacheKey, LoadedViTMatteModel] = {} + service = ViTMatteLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache=cache, + ) + + first = service.load_model("vitmatte-small-composition-1k", auto_download=False) + second = service.load_model("vitmatte-base-composition-1k", auto_download=False) + + assert second is not first + assert state.model_paths == [ + str(tmp_path / "vitmatte"), + str(tmp_path / "vitmatte-base-composition-1k"), + ] + assert len(cache) == 2 + + +def test_vitmatte_loader_does_not_cache_failed_transformers_load( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed ViTMatte transformers load leaves the cache empty for retry.""" + + _write_vitmatte_snapshot(tmp_path / "vitmatte") + state = _install_fake_transformers(monkeypatch, fail_once=True) + cache: dict[ViTMatteModelCacheKey, LoadedViTMatteModel] = {} + service = ViTMatteLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache=cache, + ) + + with pytest.raises(RuntimeError, match="ViTMatte failed"): + service.load_model("vitmatte-small-composition-1k", auto_download=False) + + loaded = service.load_model("vitmatte-small-composition-1k", auto_download=False) + + assert isinstance(loaded, LoadedViTMatteModel) + assert len(state.model_paths) == 2 + assert len(cache) == 1 + + +def _write_vitmatte_snapshot(path: Path) -> None: + """Write a minimal valid ViTMatte directory.""" + + path.mkdir(parents=True, exist_ok=True) + (path / "config.json").write_text("{}", encoding="utf-8") + (path / "preprocessor_config.json").write_text("{}", encoding="utf-8") + (path / "model.safetensors").write_bytes(b"weights") + + +@dataclass +class _FakeTransformersState: + """Record fake Transformers ViTMatte loads.""" + + model_paths: list[str] = field(default_factory=list) + processor_paths: list[str] = field(default_factory=list) + fail_once: bool = False + + +def _install_fake_transformers( + monkeypatch: pytest.MonkeyPatch, + fail_once: bool = False, +) -> _FakeTransformersState: + """Install fake transformers ViTMatte classes.""" + + state = _FakeTransformersState(fail_once=fail_once) + + class FakeModel: + """Minimal model fake.""" + + @classmethod + def from_pretrained( + cls, + path: str, + local_files_only: bool, + ) -> FakeModel: + """Return a fake model.""" + + assert local_files_only is True + state.model_paths.append(path) + if state.fail_once: + state.fail_once = False + raise RuntimeError("ViTMatte failed") + return cls() + + def eval(self) -> None: + """Accept eval mode.""" + + class FakeProcessor: + """Minimal processor fake.""" + + @classmethod + def from_pretrained( + cls, + path: str, + local_files_only: bool, + ) -> FakeProcessor: + """Return a fake processor.""" + + assert local_files_only is True + state.processor_paths.append(path) + return cls() + + module = ModuleType("transformers") + module.VitMatteForImageMatting = FakeModel # type: ignore[attr-defined] + module.VitMatteImageProcessor = FakeProcessor # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "transformers", module) + return state diff --git a/tests/test_vitmatte_model_loader_node.py b/tests/test_vitmatte_model_loader_node.py new file mode 100644 index 0000000..6b04187 --- /dev/null +++ b/tests/test_vitmatte_model_loader_node.py @@ -0,0 +1,126 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the ViTMatte Model Loader node declaration.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from simple_syrup.nodes.vitmatte_model_loader import ViTMatteModelLoader +from simple_syrup.runtime.model_choices import NO_LOCAL_VITMATTE_MODELS + + +def test_vitmatte_model_loader_contract() -> None: + """ViTMatte loader exposes the conventional VITMATTE_MODEL socket.""" + + assert ViTMatteModelLoader.RETURN_TYPES == ("VITMATTE_MODEL",) + assert ViTMatteModelLoader.RETURN_NAMES == ("vitmatte_model",) + assert ViTMatteModelLoader.FUNCTION == "load_model" + assert ViTMatteModelLoader.CATEGORY == "SimpleSyrup/Masking" + + +def test_vitmatte_model_loader_declares_expected_inputs() -> None: + """ViTMatte loader inputs are asset-only and deterministic.""" + + input_types: dict[str, dict[str, tuple[Any, ...]]] = ( + ViTMatteModelLoader.INPUT_TYPES() + ) + required = input_types["required"] + + assert set(required) == {"vitmatte_model"} + assert required["vitmatte_model"][0] == [ + "vitmatte-small-composition-1k", + "vitmatte-base-composition-1k", + ] + + +def test_vitmatte_model_loader_uses_settings_aware_choices() -> None: + """ViTMatte loader dropdown choices come from the choice service.""" + + class FakeChoices: + """Choice service double for INPUT_TYPES.""" + + def vitmatte_choices(self) -> list[str]: + """Return local-only choices.""" + + return ["vitmatte-base-composition-1k"] + + original = ViTMatteModelLoader._choices + ViTMatteModelLoader._choices = FakeChoices() # type: ignore[assignment] + try: + required = ViTMatteModelLoader.INPUT_TYPES()["required"] + finally: + ViTMatteModelLoader._choices = original + + assert required["vitmatte_model"][0] == ["vitmatte-base-composition-1k"] + assert required["vitmatte_model"][1]["default"] == "vitmatte-base-composition-1k" + + +def test_vitmatte_model_loader_delegates_to_service() -> None: + """Node execution delegates to the loader service.""" + + expected = object() + + class FakeService: + """Service double for node delegation.""" + + def load_model(self, **kwargs: object) -> object: + """Return a fixed model object.""" + + return expected + + node = ViTMatteModelLoader() + original = ViTMatteModelLoader._service + ViTMatteModelLoader._service = FakeService() # type: ignore[assignment] + try: + result = node.load_model( + vitmatte_model="vitmatte-small-composition-1k", + ) + finally: + ViTMatteModelLoader._service = original + + assert result == (expected,) + + +def test_vitmatte_model_loader_rejects_sentinel_selection() -> None: + """ViTMatte loader rejects no-local-model sentinel selections.""" + + node = ViTMatteModelLoader() + + with pytest.raises(ValueError, match="No local ViTMatte models are available"): + node.load_model(vitmatte_model=NO_LOCAL_VITMATTE_MODELS) + + +def test_vitmatte_model_loader_always_allows_downloads_for_selected_models() -> None: + """Selected downloadable models are resolved with internal download enabled.""" + + class FakeService: + """Service double that records node download policy.""" + + def __init__(self) -> None: + """Create a recording service double.""" + + self.kwargs: dict[str, object] | None = None + + def load_model(self, **kwargs: object) -> object: + """Record call arguments and return a fixed model object.""" + + self.kwargs = kwargs + return object() + + fake_service = FakeService() + original = ViTMatteModelLoader._service + ViTMatteModelLoader._service = fake_service # type: ignore[assignment] + try: + ViTMatteModelLoader().load_model( + vitmatte_model="vitmatte-small-composition-1k", + ) + finally: + ViTMatteModelLoader._service = original + + assert fake_service.kwargs is not None + assert fake_service.kwargs["auto_download"] is True diff --git a/tests/test_vitmatte_refiner.py b/tests/test_vitmatte_refiner.py new file mode 100644 index 0000000..e66efc0 --- /dev/null +++ b/tests/test_vitmatte_refiner.py @@ -0,0 +1,137 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for ViTMatte mask refinement.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest +import torch + +from simple_syrup.masking.mask_ops import MaskRefinementSettings +from simple_syrup.runtime.loaded_models import LoadedViTMatteModel +from simple_syrup.runtime.model_device_manager import TorchModelDeviceManager +from simple_syrup.runtime.vitmatte_refiner import ( + ViTMatteRefiner, + generate_vitmatte_trimap, +) +from test_helpers import make_image_tensor + + +class FakeProcessor: + """Processor double that returns tensor inputs.""" + + def __call__(self, **kwargs: object) -> dict[str, torch.Tensor]: + """Return deterministic tensor inputs.""" + + return {"pixel_values": torch.zeros((1, 3, 2, 2), dtype=torch.float32)} + + +class FakeModel: + """ViTMatte model double.""" + + def __init__(self) -> None: + """Create a recording fake model.""" + + self.devices: list[str] = [] + + def to(self, device: object) -> None: + """Record device movement.""" + + self.devices.append(str(device)) + + def eval(self) -> None: + """Accept eval mode.""" + + def __call__(self, **kwargs: object) -> object: + """Return a deterministic alpha matte.""" + + return SimpleNamespace(alphas=torch.ones((1, 1, 2, 2), dtype=torch.float32)) + + +def test_generate_vitmatte_trimap_marks_unknown_band() -> None: + """Trimap generation creates background, foreground, and unknown values.""" + + mask = torch.zeros((7, 7), dtype=torch.float32) + mask[2:5, 2:5] = 1.0 + + trimap = generate_vitmatte_trimap(mask, erode_radius=1, dilate_radius=1) + + assert 0.0 in trimap + assert 0.5 in trimap + assert 1.0 in trimap + + +def test_vitmatte_refiner_requires_connected_model() -> None: + """VITMatte refinement fails clearly without a VITMATTE_MODEL.""" + + with pytest.raises(ValueError, match="requires a connected VITMATTE_MODEL"): + ViTMatteRefiner().refine( + make_image_tensor(batch_size=1, height=2, width=2), + torch.ones((1, 2, 2), dtype=torch.float32), + _settings(), + None, + ) + + +def test_vitmatte_refiner_runs_model_and_preserves_shape( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ViTMatte refinement returns a BHW mask at the original size.""" + + _install_fake_comfy_device(monkeypatch) + model = FakeModel() + loaded = LoadedViTMatteModel( + model=model, + processor=FakeProcessor(), + source="test", + model_id="vitmatte-small-composition-1k", + model_path=tmp_path, + managed_model=TorchModelDeviceManager().manage( + model, + "vitmatte-small-composition-1k", + "test", + ), + ) + + result = ViTMatteRefiner().refine( + make_image_tensor(batch_size=1, height=4, width=4), + torch.ones((1, 4, 4), dtype=torch.float32), + _settings(max_size_pixels=4), + loaded, + ) + + assert result.shape == (1, 4, 4) + assert torch.all(result == 1.0) + assert "cpu" in model.devices + + +def _settings(max_size_pixels: int = 16) -> MaskRefinementSettings: + """Return common ViTMatte refinement settings.""" + + return MaskRefinementSettings( + detail_method="VITMatte", + detail_erode=1, + detail_dilate=1, + black_point=0.0, + white_point=1.0, + process_detail=True, + execution_device="cpu", + max_size_pixels=max_size_pixels, + ) + + +def _install_fake_comfy_device(monkeypatch: pytest.MonkeyPatch) -> None: + """Install fake Comfy model management for auto device paths.""" + + comfy = ModuleType("comfy") + model_management = ModuleType("comfy.model_management") + model_management.get_torch_device = lambda: torch.device("cpu") # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "comfy", comfy) + monkeypatch.setitem(sys.modules, "comfy.model_management", model_management) diff --git a/tests/test_wd14_tagger_loader.py b/tests/test_wd14_tagger_loader.py new file mode 100644 index 0000000..488aa1c --- /dev/null +++ b/tests/test_wd14_tagger_loader.py @@ -0,0 +1,329 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for WD14 tagger model loading and caching.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +from simple_syrup.runtime.loaded_models import LoadedWD14Tagger +from simple_syrup.runtime.model_downloads import ( + DownloadRequest, + DownloadResult, + ProgressReporter, +) +from simple_syrup.runtime.wd14_tagger import FloatArray, WD14TagRecord +from simple_syrup.runtime.wd14_tagger_loader import ( + WD14TaggerCacheKey, + WD14TaggerLoaderService, +) +from test_helpers import FakeFolderPaths + + +def test_loader_reuses_existing_files_without_download( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Existing ONNX and CSV artifacts are loaded without download requests.""" + + _install_onnxruntime(monkeypatch, ("CPUExecutionProvider",)) + _create_wd14_files(tmp_path, "wd-eva02-large-tagger-v3") + downloader = _FakeDownloader() + session_factory = _SessionFactory() + + loaded = WD14TaggerLoaderService( + downloader=downloader, + folder_paths_module=FakeFolderPaths(tmp_path), + session_factory=session_factory, + cache={}, + ).load_model("wd-eva02-large-tagger-v3", auto_download=True) + + assert loaded.model_id == "wd-eva02-large-tagger-v3" + assert loaded.source == "local" + assert loaded.providers == ("CPUExecutionProvider",) + assert loaded.tags == (WD14TagRecord("blue_hair", "0"),) + assert downloader.requests == [] + assert session_factory.calls == [ + (tmp_path / "wd14_tagger" / "wd-eva02-large-tagger-v3.onnx", loaded.providers) + ] + + +def test_loader_downloads_missing_artifacts( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing WD14 files download through the shared model downloader boundary.""" + + _install_onnxruntime(monkeypatch, ("CPUExecutionProvider",)) + downloader = _FakeDownloader() + + loaded = WD14TaggerLoaderService( + downloader=downloader, + folder_paths_module=FakeFolderPaths(tmp_path), + session_factory=_SessionFactory(), + cache={}, + ).load_model("wd-eva02-large-tagger-v3", auto_download=True) + + assert loaded.onnx_path.is_file() + assert loaded.csv_path.is_file() + assert loaded.source == "downloaded: SmilingWolf/wd-eva02-large-tagger-v3" + assert [request.destination_path.name for request in downloader.requests] == [ + "wd-eva02-large-tagger-v3.onnx", + "wd-eva02-large-tagger-v3.csv", + ] + + +def test_loader_rejects_unknown_model( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unknown WD14 selections fail before filesystem or ONNX work.""" + + _install_onnxruntime(monkeypatch, ("CPUExecutionProvider",)) + service = WD14TaggerLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + session_factory=_SessionFactory(), + cache={}, + ) + + with pytest.raises(ValueError, match="Unknown WD14 tagger model"): + service.load_model("not-a-model", auto_download=True) + + +def test_loader_fails_when_missing_and_download_disabled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing WD14 files fail clearly when auto-download is disabled.""" + + _install_onnxruntime(monkeypatch, ("CPUExecutionProvider",)) + service = WD14TaggerLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + session_factory=_SessionFactory(), + cache={}, + ) + + with pytest.raises(FileNotFoundError, match="auto_download is disabled"): + service.load_model("wd-eva02-large-tagger-v3", auto_download=False) + + +def test_loader_uses_process_cache_for_identical_resolved_model( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Identical WD14 loads reuse the same loaded container and ONNX session.""" + + _install_onnxruntime(monkeypatch, ("CPUExecutionProvider",)) + _create_wd14_files(tmp_path, "wd-eva02-large-tagger-v3") + cache: dict[WD14TaggerCacheKey, LoadedWD14Tagger] = {} + session_factory = _SessionFactory() + service = WD14TaggerLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + session_factory=session_factory, + cache=cache, + ) + + first = service.load_model("wd-eva02-large-tagger-v3", auto_download=True) + second = service.load_model("wd-eva02-large-tagger-v3", auto_download=True) + + assert second is first + assert len(session_factory.calls) == 1 + + +def test_loader_cache_separates_provider_sets( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Provider changes produce distinct cached WD14 loaded containers.""" + + _install_onnxruntime(monkeypatch, ("CPUExecutionProvider", "CUDAExecutionProvider")) + _create_wd14_files(tmp_path, "wd-eva02-large-tagger-v3") + cache: dict[WD14TaggerCacheKey, LoadedWD14Tagger] = {} + first = WD14TaggerLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + session_factory=_SessionFactory(), + providers=("CPUExecutionProvider",), + cache=cache, + ).load_model("wd-eva02-large-tagger-v3", auto_download=True) + second = WD14TaggerLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + session_factory=_SessionFactory(), + providers=("CUDAExecutionProvider", "CPUExecutionProvider"), + cache=cache, + ).load_model("wd-eva02-large-tagger-v3", auto_download=True) + + assert second is not first + assert len(cache) == 2 + + +def test_loader_does_not_cache_failed_session_creation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed WD14 session load leaves the cache empty for retry.""" + + _install_onnxruntime(monkeypatch, ("CPUExecutionProvider",)) + _create_wd14_files(tmp_path, "wd-eva02-large-tagger-v3") + cache: dict[WD14TaggerCacheKey, LoadedWD14Tagger] = {} + session_factory = _FailOnceSessionFactory() + service = WD14TaggerLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + session_factory=session_factory, + cache=cache, + ) + + with pytest.raises(RuntimeError, match="session failed"): + service.load_model("wd-eva02-large-tagger-v3", auto_download=True) + + loaded = service.load_model("wd-eva02-large-tagger-v3", auto_download=True) + + assert isinstance(loaded, LoadedWD14Tagger) + assert len(session_factory.calls) == 2 + assert len(cache) == 1 + + +def test_loader_falls_back_to_available_provider( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unavailable requested ONNX providers are filtered before session loading.""" + + _install_onnxruntime(monkeypatch, ("CPUExecutionProvider",)) + _create_wd14_files(tmp_path, "wd-eva02-large-tagger-v3") + + loaded = WD14TaggerLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + session_factory=_SessionFactory(), + providers=("CUDAExecutionProvider", "CPUExecutionProvider"), + cache={}, + ).load_model("wd-eva02-large-tagger-v3", auto_download=True) + + assert loaded.providers == ("CPUExecutionProvider",) + + +def test_loader_fails_when_no_provider_is_available( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Provider selection fails clearly when ONNX Runtime offers no usable provider.""" + + _install_onnxruntime(monkeypatch, ()) + _create_wd14_files(tmp_path, "wd-eva02-large-tagger-v3") + + with pytest.raises(RuntimeError, match="No requested ONNX Runtime providers"): + WD14TaggerLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + session_factory=_SessionFactory(), + providers=("CUDAExecutionProvider",), + cache={}, + ).load_model("wd-eva02-large-tagger-v3", auto_download=True) + + +class _FakeDownloader: + """Fake shared downloader that writes requested WD14 artifacts.""" + + def __init__(self) -> None: + """Initialize captured requests.""" + + self.requests: list[DownloadRequest] = [] + + def download( + self, + request: DownloadRequest, + progress: ProgressReporter | None = None, + ) -> DownloadResult: + """Record and satisfy a trusted download request.""" + + _ = progress + self.requests.append(request) + request.destination_path.parent.mkdir(parents=True, exist_ok=True) + if request.destination_path.suffix == ".csv": + request.destination_path.write_text( + "id,name,category\n0,blue_hair,0\n", + encoding="utf-8", + ) + else: + request.destination_path.write_bytes(b"onnx") + return DownloadResult( + path=request.destination_path, + bytes_downloaded=1, + skipped_existing=False, + ) + + +class _SessionFactory: + """Record ONNX session creation calls.""" + + def __init__(self) -> None: + """Initialize captured calls.""" + + self.calls: list[tuple[Path, tuple[str, ...]]] = [] + + def __call__(self, path: Path, providers: tuple[str, ...]) -> _FakeSession: + """Return a fake WD14 session.""" + + self.calls.append((path, providers)) + return _FakeSession() + + +class _FailOnceSessionFactory(_SessionFactory): + """Fail the first ONNX session creation and succeed afterward.""" + + def __call__(self, path: Path, providers: tuple[str, ...]) -> _FakeSession: + """Record each call and fail only the first one.""" + + self.calls.append((path, providers)) + if len(self.calls) == 1: + raise RuntimeError("session failed") + return _FakeSession() + + +class _FakeSession: + """Minimal WD14 session double.""" + + def get_inputs(self) -> list[object]: + """Return no fake inputs.""" + + return [] + + def get_outputs(self) -> list[object]: + """Return no fake outputs.""" + + return [] + + def run( + self, output_names: list[str], feeds: dict[str, FloatArray] + ) -> list[object]: + """Return no fake outputs.""" + + _ = output_names, feeds + return [] + + +def _create_wd14_files(tmp_path: Path, model_id: str) -> None: + """Create a complete local WD14 ONNX/CSV pair.""" + + model_dir = tmp_path / "wd14_tagger" + model_dir.mkdir(parents=True) + (model_dir / f"{model_id}.onnx").write_bytes(b"onnx") + (model_dir / f"{model_id}.csv").write_text( + "id,name,category\n0,blue_hair,0\n", + encoding="utf-8", + ) + + +def _install_onnxruntime( + monkeypatch: pytest.MonkeyPatch, + providers: tuple[str, ...], +) -> None: + """Install a minimal fake onnxruntime module.""" + + module = ModuleType("onnxruntime") + module.get_available_providers = lambda: list(providers) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "onnxruntime", module) diff --git a/tests/test_wd14_tagger_loader_node.py b/tests/test_wd14_tagger_loader_node.py new file mode 100644 index 0000000..76c4cb3 --- /dev/null +++ b/tests/test_wd14_tagger_loader_node.py @@ -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 + +"""Tests for the Load WD14 Tagger node declaration.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from simple_syrup.nodes.wd14_tagger_loader import WD14TaggerLoader +from simple_syrup.runtime.model_choices import NO_LOCAL_WD14_TAGGER_MODELS + + +def test_wd14_tagger_loader_contract() -> None: + """WD14 loader exposes the conventional WD14_TAGGER socket.""" + + assert WD14TaggerLoader.RETURN_TYPES == ("WD14_TAGGER",) + assert WD14TaggerLoader.RETURN_NAMES == ("wd14_tagger",) + assert WD14TaggerLoader.FUNCTION == "load_model" + assert WD14TaggerLoader.CATEGORY == "SimpleSyrup/Tagging" + + +def test_wd14_tagger_loader_declares_expected_inputs() -> None: + """WD14 loader inputs are asset-only and deterministic.""" + + input_types: dict[str, dict[str, tuple[Any, ...]]] = WD14TaggerLoader.INPUT_TYPES() + required = input_types["required"] + + assert set(required) == {"wd14_model"} + assert required["wd14_model"][0][0] == "wd-eva02-large-tagger-v3" + assert required["wd14_model"][1]["default"] == "wd-eva02-large-tagger-v3" + + +def test_wd14_tagger_loader_uses_settings_aware_choices() -> None: + """WD14 loader dropdown choices come from the choice service.""" + + class FakeChoices: + """Choice service double for INPUT_TYPES.""" + + def wd14_tagger_choices(self) -> list[str]: + """Return local-only choices.""" + + return ["wd-vit-tagger-v3"] + + original = WD14TaggerLoader._choices + WD14TaggerLoader._choices = FakeChoices() # type: ignore[assignment] + try: + required = WD14TaggerLoader.INPUT_TYPES()["required"] + finally: + WD14TaggerLoader._choices = original + + assert required["wd14_model"][0] == ["wd-vit-tagger-v3"] + assert required["wd14_model"][1]["default"] == "wd-vit-tagger-v3" + + +def test_wd14_tagger_loader_delegates_to_service() -> None: + """Node execution delegates to the loader service.""" + + expected = object() + + class FakeService: + """Service double for node delegation.""" + + def load_model(self, **kwargs: object) -> object: + """Return a fixed model object.""" + + return expected + + node = WD14TaggerLoader() + original = WD14TaggerLoader._service + WD14TaggerLoader._service = FakeService() # type: ignore[assignment] + try: + result = node.load_model(wd14_model="wd-eva02-large-tagger-v3") + finally: + WD14TaggerLoader._service = original + + assert result == (expected,) + + +def test_wd14_tagger_loader_rejects_sentinel_selection() -> None: + """WD14 loader rejects no-local-model sentinel selections.""" + + node = WD14TaggerLoader() + + with pytest.raises(ValueError, match="No local WD14 tagger models are available"): + node.load_model(wd14_model=NO_LOCAL_WD14_TAGGER_MODELS) + + +def test_wd14_tagger_loader_always_allows_downloads_for_selected_models() -> None: + """Selected downloadable WD14 models are resolved with internal download enabled.""" + + class FakeService: + """Service double that records node download policy.""" + + def __init__(self) -> None: + """Create a recording service double.""" + + self.kwargs: dict[str, object] | None = None + + def load_model(self, **kwargs: object) -> object: + """Record call arguments and return a fixed model object.""" + + self.kwargs = kwargs + return object() + + fake_service = FakeService() + original = WD14TaggerLoader._service + WD14TaggerLoader._service = fake_service # type: ignore[assignment] + try: + WD14TaggerLoader().load_model(wd14_model="wd-eva02-large-tagger-v3") + finally: + WD14TaggerLoader._service = original + + assert fake_service.kwargs is not None + assert fake_service.kwargs["auto_download"] is True diff --git a/tests/test_wd14_tagger_loader_v3_node.py b/tests/test_wd14_tagger_loader_v3_node.py new file mode 100644 index 0000000..64d9702 --- /dev/null +++ b/tests/test_wd14_tagger_loader_v3_node.py @@ -0,0 +1,53 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Load WD14 Tagger Comfy v3 wrapper.""" + +from __future__ import annotations + +from typing import Any + +from simple_syrup.nodes.wd14_tagger_loader import WD14TaggerLoader +from simple_syrup.nodes_v3.wd14_tagger_loader import WD14TaggerLoaderV3 + + +def test_wd14_tagger_loader_v3_schema() -> None: + """The v3 loader schema exposes the WD14 tagger loader contract.""" + + schema = WD14TaggerLoaderV3.define_schema() + + assert schema.node_id == "SimpleSyrup.WD14TaggerLoader" + assert schema.display_name == "Load WD14 Tagger" + assert [input_item.id for input_item in schema.inputs] == ["wd14_model"] + assert schema.inputs[0].default == "wd-eva02-large-tagger-v3" + assert [output.id for output in schema.outputs] == ["wd14_tagger"] + assert schema.outputs[0].io_type == "WD14_TAGGER" + + +def test_wd14_tagger_loader_v3_execute_forwards_to_legacy_loader( + monkeypatch: Any, +) -> None: + """The v3 loader wrapper forwards execution to the legacy loader.""" + + expected = object() + + class FakeService: + """Service double for the legacy loader.""" + + def __init__(self) -> None: + """Initialize captured kwargs.""" + + self.kwargs: dict[str, object] = {} + + def load_model(self, **kwargs: object) -> object: + """Return a fixed loaded tagger object.""" + + self.kwargs = kwargs + return expected + + fake_service = FakeService() + monkeypatch.setattr(WD14TaggerLoader, "_service", fake_service) + + assert WD14TaggerLoaderV3.execute("wd-eva02-large-tagger-v3") == (expected,) + assert fake_service.kwargs["wd14_model"] == "wd-eva02-large-tagger-v3" diff --git a/tests/test_wd14_tagger_runtime.py b/tests/test_wd14_tagger_runtime.py new file mode 100644 index 0000000..c9ca7e6 --- /dev/null +++ b/tests/test_wd14_tagger_runtime.py @@ -0,0 +1,236 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the batched WD14 tagger runtime.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np +import pytest +import torch +from numpy.typing import NDArray + +from simple_syrup.runtime.loaded_models import LoadedWD14Tagger +from simple_syrup.runtime.wd14_tagger import ( + WD14TagFormattingControls, + WD14Tagger, + WD14TagRecord, +) + +FloatArray = NDArray[np.float32] + + +def test_wd14_tagger_batches_images_and_preserves_order() -> None: + """One inference receives the full batch and returns tags by image order.""" + + progress = _ProgressRecorder() + session = _FakeSession( + np.array( + [ + [0.0, 0.9, 0.1, 0.2], + [0.0, 0.2, 0.8, 0.7], + ], + dtype=np.float32, + ) + ) + loaded_tagger = _loaded_tagger(session) + tagger = WD14Tagger() + + tags = tagger.tag_images( + loaded_tagger, + (_image(0.1), _image(0.9)), + _controls(threshold=0.5, character_threshold=0.5), + progress=progress, + ) + + assert tags == ("blue hair", "smile, cirno") + assert session.batch_shapes == [(2, 448, 448, 3)] + assert progress.updates == [2] + + +def test_wd14_tagger_chunks_without_reordering() -> None: + """Internal chunks are concatenated in original tile order.""" + + progress = _ProgressRecorder() + session = _FakeSession( + np.array( + [ + [0.0, 0.9, 0.1, 0.2], + [0.0, 0.2, 0.8, 0.7], + [0.0, 0.6, 0.6, 0.1], + ], + dtype=np.float32, + ) + ) + loaded_tagger = _loaded_tagger(session) + tagger = WD14Tagger(chunk_size=2) + + tags = tagger.tag_images( + loaded_tagger, + (_image(0.1), _image(0.9), _image(0.5)), + _controls(threshold=0.5, character_threshold=0.5), + progress=progress, + ) + + assert tags == ("blue hair", "smile, cirno", "blue hair, smile") + assert session.batch_shapes == [(2, 448, 448, 3), (1, 448, 448, 3)] + assert progress.updates == [2, 1] + + +def test_wd14_tagger_applies_formatting_and_exclusions() -> None: + """Tag formatting follows WD14 controls.""" + + session = _FakeSession(np.array([[0.0, 0.9, 0.8, 0.0]], dtype=np.float32)) + loaded_tagger = _loaded_tagger(session) + tagger = WD14Tagger() + + tags = tagger.tag_images( + loaded_tagger, + (_image(0.1),), + _controls( + threshold=0.5, + character_threshold=0.5, + replace_underscore=False, + trailing_comma=True, + exclude_tags="smile", + ), + ) + + assert tags == ("blue_hair, ",) + + +def test_wd14_tagger_rejects_invalid_output_shape() -> None: + """Bad ONNX output shapes fail before tags can be misaligned.""" + + loaded_tagger = _loaded_tagger(_FakeSession(np.array([0.1, 0.2], dtype=np.float32))) + tagger = WD14Tagger() + + with pytest.raises(ValueError, match="WD14 output shape"): + tagger.tag_images(loaded_tagger, (_image(0.1),), _controls()) + + +def test_wd14_tagger_rejects_output_tag_count_mismatch() -> None: + """Model outputs must match the loaded selected tag records.""" + + loaded_tagger = _loaded_tagger( + _FakeSession(np.array([[0.9, 0.2]], dtype=np.float32)) + ) + + with pytest.raises(ValueError, match="selected_tags.csv"): + WD14Tagger().tag_images(loaded_tagger, (_image(0.1),), _controls()) + + +def test_wd14_tagger_returns_empty_tuple_for_no_images() -> None: + """Empty image batches return no tags without touching the loaded session.""" + + loaded_tagger = _loaded_tagger( + _FakeSession(np.array([[0.9, 0.2, 0.1, 0.0]], dtype=np.float32)) + ) + + assert WD14Tagger().tag_images(loaded_tagger, (), _controls()) == () + + +class _FakeSession: + """Fake ONNX session with visible batched inputs.""" + + def __init__(self, outputs: FloatArray) -> None: + """Store fixed output rows.""" + + self.outputs = outputs + self.offset = 0 + self.batch_shapes: list[tuple[int, ...]] = [] + + def get_inputs(self) -> list[Any]: + """Return one fake model input.""" + + return [_FakeIO("input", [None, 448, 448, 3])] + + def get_outputs(self) -> list[Any]: + """Return one fake model output.""" + + return [_FakeIO("output", [None, 4])] + + def run( + self, output_names: list[str], feeds: dict[str, FloatArray] + ) -> list[FloatArray]: + """Return rows matching the provided batch size.""" + + _ = output_names + batch = feeds["input"] + self.batch_shapes.append(tuple(batch.shape)) + count = int(batch.shape[0]) + rows = self.outputs[self.offset : self.offset + count] + self.offset += count + return [rows] + + +class _FakeIO: + """Simple ONNX IO metadata object.""" + + def __init__(self, name: str, shape: list[int | None]) -> None: + """Store IO metadata.""" + + self.name = name + self.shape = shape + + +class _ProgressRecorder: + """Record WD14 progress updates.""" + + def __init__(self) -> None: + """Initialize captured updates.""" + + self.updates: list[int] = [] + + def update(self, value: int) -> None: + """Record one progress update.""" + + self.updates.append(value) + + +def _loaded_tagger(session: _FakeSession) -> LoadedWD14Tagger: + """Create loaded WD14 tagger metadata around a fake session.""" + + return LoadedWD14Tagger( + model_id="wd-eva02-large-tagger-v3", + source="test", + onnx_path=Path("wd-eva02-large-tagger-v3.onnx"), + csv_path=Path("wd-eva02-large-tagger-v3.csv"), + providers=("CPUExecutionProvider",), + session=session, + tags=( + WD14TagRecord("rating:safe", "9"), + WD14TagRecord("blue_hair", "0"), + WD14TagRecord("smile", "0"), + WD14TagRecord("cirno", "4"), + ), + ) + + +def _controls( + *, + threshold: float = 0.35, + character_threshold: float = 1.0, + replace_underscore: bool = True, + trailing_comma: bool = False, + exclude_tags: str = "", +) -> WD14TagFormattingControls: + """Return WD14 controls for runtime tests.""" + + return WD14TagFormattingControls( + threshold=threshold, + character_threshold=character_threshold, + replace_underscore=replace_underscore, + trailing_comma=trailing_comma, + exclude_tags=exclude_tags, + ) + + +def _image(value: float) -> torch.Tensor: + """Return a small single-image BHWC tensor.""" + + return torch.full((1, 8, 8, 3), value, dtype=torch.float32) diff --git a/third_party/NOTICE.md b/third_party/NOTICE.md new file mode 100644 index 0000000..ef1fd92 --- /dev/null +++ b/third_party/NOTICE.md @@ -0,0 +1,57 @@ +# Third-Party Notices + +This repository vendors selected third-party behavior for runtime use. Each +vendored component is recorded in `third_party/manifest.toml`, and the +corresponding license text is stored in `third_party/licenses/`. + +## SAM-HQ and MobileSAM runtime + +SimpleSyrup vendors selected SAM-HQ and MobileSAM runtime files under +Apache-2.0 for loading SAM-family segmentation models inside ComfyUI. The +vendored runtime is kept under `simple_syrup/third_party/sam_hq_runtime/` +and preserves upstream copyright notices where the source files carried them. + +## GroundingDINO runtime + +SimpleSyrup vendors selected GroundingDINO runtime files under Apache-2.0 for +prompt-based box detection. The vendored runtime is kept under +`simple_syrup/third_party/groundingdino_runtime/` and preserves upstream +copyright notices where the source files carried them. + +## RES4LYF beta57 scheduler preset + +SimpleSyrup vendors the RES4LYF `beta57` scheduler preset under AGPL-3.0. +The preset uses ComfyUI's beta scheduler with `alpha=0.5` and `beta=0.7`. +SimpleSyrup resolves the preset locally for `KSampler (Extras)` and does not +patch ComfyUI's global scheduler registry. + +## AUTOMATIC1111 Euler a sampler integration + +SimpleSyrup vendors selected AUTOMATIC1111 WebUI sampler integration behavior +under AGPL-3.0. This provenance covers the `Euler a` sampler mapping, the +`Automatic` scheduler fallback behavior, and the documented decision not to +port AUTOMATIC1111 ENSD or RNG hijacking behavior. + +## k-diffusion Euler ancestral sampler + +SimpleSyrup vendors the Euler ancestral sampler loop from k-diffusion under +the MIT license. The local sampler keeps the A1111/k-diffusion loop structure +while running inside ComfyUI's deterministic seed system. + +## Mixture of Diffusers and MultiDiffusion tiled diffusion behavior + +SimpleSyrup reimplements tiled denoising behavior after +inspecting the local `multidiffusion-upscaler-for-automatic1111` extension, +which is licensed under CC-BY-NC-SA-4.0. The implementation preserves the +extension's latent tile planning, Mixture Gaussian tile weighting, +MultiDiffusion uniform tile averaging, regional prompt mask blending, and +pre-CFG model prediction blending behavior without importing the extension at +runtime. + +## SmilingWolf WD tagger models + +SimpleSyrup's `Tile & Tag SEGS` node can download selected SmilingWolf WD +tagger ONNX models and `selected_tags.csv` files at runtime from Hugging Face. +These model files are not vendored in this repository. The runtime catalog +points to the corresponding `SmilingWolf/*` repositories and stores downloaded +files in the user's ComfyUI model directory. diff --git a/third_party/licenses/automatic1111.LICENSE.txt b/third_party/licenses/automatic1111.LICENSE.txt new file mode 100644 index 0000000..211d32e --- /dev/null +++ b/third_party/licenses/automatic1111.LICENSE.txt @@ -0,0 +1,663 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (c) 2023 AUTOMATIC1111 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/third_party/licenses/groundingdino.LICENSE.txt b/third_party/licenses/groundingdino.LICENSE.txt new file mode 100644 index 0000000..f1460f5 --- /dev/null +++ b/third_party/licenses/groundingdino.LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 - present, IDEA Research. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/licenses/k-diffusion.LICENSE.txt b/third_party/licenses/k-diffusion.LICENSE.txt new file mode 100644 index 0000000..37a4236 --- /dev/null +++ b/third_party/licenses/k-diffusion.LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2022 Katherine Crowson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third_party/licenses/multidiffusion-upscaler.LICENSE.txt b/third_party/licenses/multidiffusion-upscaler.LICENSE.txt new file mode 100644 index 0000000..bfef380 --- /dev/null +++ b/third_party/licenses/multidiffusion-upscaler.LICENSE.txt @@ -0,0 +1,437 @@ +Attribution-NonCommercial-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International +Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-NonCommercial-ShareAlike 4.0 International Public License +("Public License"). To the extent this Public License may be +interpreted as a contract, You are granted the Licensed Rights in +consideration of Your acceptance of these terms and conditions, and the +Licensor grants You such rights in consideration of benefits the +Licensor receives from making the Licensed Material available under +these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-NC-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution, NonCommercial, and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. NonCommercial means not primarily intended for or directed towards + commercial advantage or monetary compensation. For purposes of + this Public License, the exchange of the Licensed Material for + other material subject to Copyright and Similar Rights by digital + file-sharing or similar means is NonCommercial provided there is + no payment of monetary compensation in connection with the + exchange. + + l. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + m. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + n. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part, for NonCommercial purposes only; and + + b. produce, reproduce, and Share Adapted Material for + NonCommercial purposes only. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties, including when + the Licensed Material is used other than for NonCommercial + purposes. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-NC-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database for NonCommercial purposes + only; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + including for purposes of Section 3(b); and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. \ No newline at end of file diff --git a/third_party/licenses/res4lyf.LICENSE.txt b/third_party/licenses/res4lyf.LICENSE.txt new file mode 100644 index 0000000..d177238 --- /dev/null +++ b/third_party/licenses/res4lyf.LICENSE.txt @@ -0,0 +1,669 @@ +The use of this software or any derivative work for the purpose of +providing a commercial service, such as (but not limited to) an +AI image generation service, is strictly prohibited without obtaining +permission and/or a separate commercial license from the copyright holder. +This includes any service that charges users directly or indirectly for +access to this software's functionality, whether standalone or integrated +into a larger product. + + GNU AFFERO GENERAL PUBLIC + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/third_party/licenses/sam-hq.LICENSE.txt b/third_party/licenses/sam-hq.LICENSE.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/third_party/licenses/sam-hq.LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/manifest.toml b/third_party/manifest.toml new file mode 100644 index 0000000..2ec5b95 --- /dev/null +++ b/third_party/manifest.toml @@ -0,0 +1,131 @@ +[[component]] +name = "SAM-HQ and MobileSAM runtime" +license = "Apache-2.0" +license_file = "third_party/licenses/sam-hq.LICENSE.txt" +source = "https://github.com/SysCV/sam-hq" +revision = "local-layerstyle-compatible-runtime" +source_paths = [ + "sam-hq/segment_anything/build_sam_hq.py", + "sam-hq/segment_anything/predictor.py", + "sam-hq/segment_anything/automatic.py", + "sam-hq/segment_anything/modeling", +] +vendored_files = [ + "simple_syrup/third_party/sam_hq_runtime/__init__.py", + "simple_syrup/third_party/sam_hq_runtime/automatic.py", + "simple_syrup/third_party/sam_hq_runtime/build_sam_hq.py", + "simple_syrup/third_party/sam_hq_runtime/predictor.py", + "simple_syrup/third_party/sam_hq_runtime/modeling/__init__.py", + "simple_syrup/third_party/sam_hq_runtime/modeling/image_encoder.py", + "simple_syrup/third_party/sam_hq_runtime/modeling/mask_decoder_hq.py", + "simple_syrup/third_party/sam_hq_runtime/modeling/tiny_vit.py", +] + +[[component]] +name = "GroundingDINO runtime" +license = "Apache-2.0" +license_file = "third_party/licenses/groundingdino.LICENSE.txt" +source = "https://github.com/IDEA-Research/GroundingDINO" +revision = "local-layerstyle-compatible-runtime" +source_paths = [ + "groundingdino/datasets/transforms.py", + "groundingdino/models", + "groundingdino/util", +] +vendored_files = [ + "simple_syrup/third_party/groundingdino_runtime/__init__.py", + "simple_syrup/third_party/groundingdino_runtime/datasets/__init__.py", + "simple_syrup/third_party/groundingdino_runtime/datasets/transforms.py", + "simple_syrup/third_party/groundingdino_runtime/models/__init__.py", + "simple_syrup/third_party/groundingdino_runtime/models/registry.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/__init__.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/bertwarper.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/fuse_modules.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/groundingdino.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/ms_deform_attn.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer_vanilla.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/utils.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/__init__.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/backbone.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/position_encoding.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/backbone/swin_transformer.py", + "simple_syrup/third_party/groundingdino_runtime/util/__init__.py", + "simple_syrup/third_party/groundingdino_runtime/util/box_ops.py", + "simple_syrup/third_party/groundingdino_runtime/util/get_tokenlizer.py", + "simple_syrup/third_party/groundingdino_runtime/util/inference.py", + "simple_syrup/third_party/groundingdino_runtime/util/misc.py", + "simple_syrup/third_party/groundingdino_runtime/util/slconfig.py", + "simple_syrup/third_party/groundingdino_runtime/util/slio.py", + "simple_syrup/third_party/groundingdino_runtime/util/utils.py", +] + +[[component]] +name = "RES4LYF beta57 scheduler preset" +license = "AGPL-3.0" +license_file = "third_party/licenses/res4lyf.LICENSE.txt" +source = "https://github.com/ClownsharkBatwing/RES4LYF" +revision = "1c9bf61" +source_paths = [ + "sigmas.py", + "res4lyf.py", + "README.md", +] +vendored_files = [ + "simple_syrup/runtime/sampling_schedulers.py", +] + +[[component]] +name = "AUTOMATIC1111 Euler a sampler integration" +license = "AGPL-3.0" +license_file = "third_party/licenses/automatic1111.LICENSE.txt" +source = "https://github.com/AUTOMATIC1111/stable-diffusion-webui" +revision = "0120768f" +source_paths = [ + "modules/sd_samplers_kdiffusion.py", + "modules/sd_samplers_common.py", + "modules/sd_schedulers.py", + "modules/rng.py", +] +vendored_files = [ + "simple_syrup/runtime/a1111_sampling.py", + "simple_syrup/runtime/sampling_samplers.py", + "simple_syrup/runtime/sampling_schedulers.py", +] + +[[component]] +name = "k-diffusion Euler ancestral sampler" +license = "MIT" +license_file = "third_party/licenses/k-diffusion.LICENSE.txt" +source = "https://github.com/crowsonkb/k-diffusion" +revision = "ab527a9" +source_paths = [ + "k_diffusion/sampling.py", +] +vendored_files = [ + "simple_syrup/runtime/a1111_sampling.py", +] + +[[component]] +name = "Mixture of Diffusers and MultiDiffusion tiled diffusion behavior" +license = "CC-BY-NC-SA-4.0" +license_file = "third_party/licenses/multidiffusion-upscaler.LICENSE.txt" +source = "https://github.com/pkuliyi2015/multidiffusion-upscaler-for-automatic1111" +revision = "local-2024-09-25" +source_paths = [ + "tile_methods/abstractdiffusion.py", + "tile_methods/mixtureofdiffusers.py", + "tile_methods/multidiffusion.py", + "tile_utils/utils.py", + "scripts/tilediffusion.py", +] +vendored_files = [ + "simple_syrup/domain/regional_detailing.py", + "simple_syrup/domain/tiled_diffusion.py", + "simple_syrup/masking/regional_detailing_masks.py", + "simple_syrup/runtime/mixture_of_diffusers_sampling.py", + "simple_syrup/runtime/multidiffusion_sampling.py", + "simple_syrup/runtime/regional_multidiffusion_sampling.py", + "simple_syrup/runtime/tiled_sampling.py", + "simple_syrup/services/detail_segs_as_regions_service.py", +] diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..5e1a6d8 --- /dev/null +++ b/tools/__init__.py @@ -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 + +"""Repository maintenance tools for SimpleSyrup.""" diff --git a/tools/add_license_headers.py b/tools/add_license_headers.py new file mode 100644 index 0000000..7ae0860 --- /dev/null +++ b/tools/add_license_headers.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Add or update AGPLv3 license headers in SimpleSyrup-owned source files.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path + +PROJECT_LINE = "SimpleSyrup - workflow-focused ComfyUI extensions for image generation" +COPYRIGHT_HOLDER = "Artificial Sweetener and contributors" +LICENSE_ID = "AGPL-3.0-or-later" +START_YEAR = 2026 +SUPPORTED_SUFFIXES = frozenset((".py", ".pyi", ".ts", ".js", ".mjs", ".cjs")) +EXCLUDED_PARTS = frozenset( + ( + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "node_modules", + "__pycache__", + ) +) +EXCLUDED_PREFIXES = ( + Path("simple_syrup/third_party"), + Path("third_party/licenses"), + Path("web/dist"), +) +PROJECT_MARKER_PATTERN = re.compile( + r"^(?P#|//) SimpleSyrup - .+$", re.MULTILINE +) + + +def _copyright_years(now: datetime | None = None) -> str: + """Return the canonical copyright year text for the run date.""" + + current_year = (now or datetime.now(UTC)).year + if current_year <= START_YEAR: + return str(START_YEAR) + return f"{START_YEAR} - {current_year}" + + +def _comment_prefix(path: Path) -> str: + """Return the line comment prefix for a supported source file.""" + + if path.suffix in {".js", ".mjs", ".cjs", ".ts"}: + return "//" + return "#" + + +def _header(path: Path, now: datetime | None = None) -> str: + """Return the canonical first-party license header for a source file.""" + + prefix = _comment_prefix(path) + return "\n".join( + ( + f"{prefix} {PROJECT_LINE}", + f"{prefix} Copyright (C) {_copyright_years(now)} {COPYRIGHT_HOLDER}", + f"{prefix} SPDX-License-Identifier: {LICENSE_ID}", + ) + ) + + +def _tracked_source_files() -> list[Path]: + """Return git-tracked source files that should carry the project notice.""" + + try: + result = subprocess.run( + ["git", "ls-files"], + capture_output=True, + check=True, + text=True, + ) + except subprocess.CalledProcessError as exc: + print(f"Error running git ls-files: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + return sorted( + path + for path_text in result.stdout.splitlines() + if _should_update(path := Path(path_text)) + ) + + +def _should_update(path: Path) -> bool: + """Return whether a repository path is SimpleSyrup-owned source.""" + + if path.suffix not in SUPPORTED_SUFFIXES: + return False + if any(part in EXCLUDED_PARTS for part in path.parts): + return False + return not any(_is_relative_to(path, prefix) for prefix in EXCLUDED_PREFIXES) + + +def _is_relative_to(path: Path, prefix: Path) -> bool: + """Return whether path is inside prefix without requiring Python 3.12.""" + + try: + path.relative_to(prefix) + except ValueError: + return False + return True + + +def _header_bounds(lines: list[str], prefix: str) -> tuple[int, int] | None: + """Find an existing SimpleSyrup license header block in source lines.""" + + start = None + project_pattern = f"{prefix} SimpleSyrup - " + for index, line in enumerate(lines): + if line.startswith(project_pattern): + start = index + break + + if start is None: + return None + + expected_final_prefix = f"{prefix} SPDX-License-Identifier:" + for index in range(start, min(start + 8, len(lines))): + if lines[index].startswith(expected_final_prefix): + return start, index + + return None + + +def _insertion_index(lines: list[str], prefix: str) -> int: + """Return the safe insertion point after shebangs or encoding directives.""" + + index = 0 + if lines and lines[0].startswith("#!"): + index += 1 + + if prefix == "#" and len(lines) > index: + encoding_line = lines[index] + if encoding_line.startswith("#") and "coding" in encoding_line: + index += 1 + + return index + + +def update_header(path: Path) -> bool: + """Add or normalize the project license header for one source file.""" + + if not _should_update(path): + return False + + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + print(f"Skipping {path}: unable to read as UTF-8") + return False + + lines = content.splitlines(keepends=True) + prefix = _comment_prefix(path) + header = _header(path) + header_lines = [line + "\n" for line in header.splitlines()] + bounds = _header_bounds(lines, prefix) + + if bounds is not None: + start, end = bounds + updated_lines = lines[:start] + header_lines + lines[end + 1 :] + updated_content = "".join(updated_lines) + if updated_content != content: + path.write_text(updated_content, encoding="utf-8") + print(f"Normalized header in {path}") + return True + return False + + if PROJECT_MARKER_PATTERN.search(content[:1500]): + print(f"Skipping {path}: unknown SimpleSyrup header already present") + return False + + insert_at = _insertion_index(lines, prefix) + new_lines = lines[:insert_at] + header_lines + ["\n"] + lines[insert_at:] + path.write_text("".join(new_lines), encoding="utf-8") + print(f"Added header to {path}") + return True + + +def main() -> None: + """Update license headers in all tracked SimpleSyrup-owned source files.""" + + files = _tracked_source_files() + print(f"Found {len(files)} tracked source files.") + changed = 0 + for path in files: + if path.exists() and update_header(path): + changed += 1 + print(f"Updated {changed} file(s).") + + +if __name__ == "__main__": + main() diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..300a5b5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM"], + "strict": true, + "noEmit": true, + "skipLibCheck": false, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "types": ["vitest/globals", "node"] + }, + "include": ["web/src/**/*.ts", "web/tests/**/*.ts", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ecf2e72 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["web/tests/**/*.test.ts"] + } +}); diff --git a/web/dist/simple-syrup.js b/web/dist/simple-syrup.js new file mode 100644 index 0000000..30a2579 --- /dev/null +++ b/web/dist/simple-syrup.js @@ -0,0 +1,92 @@ +// web/src/main.ts +import { app } from "../../../scripts/app.js"; + +// web/src/api.ts +var SETTINGS_ROUTE = "/simple-syrup/settings"; +async function getSettings(fetchImpl = fetch) { + const response = await fetchImpl(SETTINGS_ROUTE); + if (!response.ok) { + throw new Error( + `Could not load SimpleSyrup settings. Backend returned ${String(response.status)}.` + ); + } + return parseSettings(await response.json()); +} +async function saveSettings(settings, fetchImpl = fetch) { + const response = await fetchImpl(SETTINGS_ROUTE, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings) + }); + if (!response.ok) { + throw new Error( + `Could not save SimpleSyrup settings. Backend returned ${String(response.status)}.` + ); + } + return parseSettings(await response.json()); +} +function parseSettings(payload) { + if (!isSettingsPayload(payload)) { + throw new Error( + "SimpleSyrup settings payload is invalid. Expected show_downloadable_models to be a boolean." + ); + } + return { + show_downloadable_models: payload.show_downloadable_models + }; +} +function isSettingsPayload(payload) { + return typeof payload === "object" && payload !== null && typeof payload.show_downloadable_models === "boolean"; +} + +// web/src/settings.ts +var SIMPLE_SYRUP_SETTING_ID = "SimpleSyrup.ShowDownloadableModels"; +var SIMPLE_SYRUP_SETTING_LABEL = "SimpleSyrup: Show downloadable models in loader dropdowns"; +var SIMPLE_SYRUP_SETTING_DESCRIPTION = "Show known downloadable SAM, GroundingDINO, and ViTMatte models even when they are not installed locally."; +var DEFAULT_SETTINGS = { + show_downloadable_models: true +}; +async function registerSimpleSyrupSettings(app2, api = { getSettings, saveSettings }, logger = console) { + let initialSettings = DEFAULT_SETTINGS; + try { + initialSettings = await api.getSettings(); + } catch (error) { + logger.warn( + "Could not load SimpleSyrup settings. Using the default setting until the backend is available.", + error + ); + } + let savedSettings = initialSettings; + const setting = app2.ui.settings.addSetting({ + id: SIMPLE_SYRUP_SETTING_ID, + name: SIMPLE_SYRUP_SETTING_LABEL, + type: "boolean", + defaultValue: initialSettings.show_downloadable_models, + tooltip: SIMPLE_SYRUP_SETTING_DESCRIPTION, + onChange: async (value) => { + try { + const saved = await api.saveSettings({ + show_downloadable_models: value + }); + savedSettings = saved; + setting.value = saved.show_downloadable_models; + } catch (error) { + logger.warn( + "Could not save SimpleSyrup settings. The backend rejected the setting update.", + error + ); + setting.value = savedSettings.show_downloadable_models; + } + } + }); + setting.value = initialSettings.show_downloadable_models; +} + +// web/src/main.ts +var comfyApp = app; +comfyApp.registerExtension({ + name: "SimpleSyrup.Settings", + async setup(appInstance) { + await registerSimpleSyrupSettings(appInstance); + } +}); diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..ab7f064 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,63 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +export interface SimpleSyrupSettings { + show_downloadable_models: boolean; +} + +export type FetchLike = ( + input: RequestInfo | URL, + init?: RequestInit +) => Promise; + +const SETTINGS_ROUTE = "/simple-syrup/settings"; + +export async function getSettings( + fetchImpl: FetchLike = fetch +): Promise { + const response = await fetchImpl(SETTINGS_ROUTE); + if (!response.ok) { + throw new Error( + `Could not load SimpleSyrup settings. Backend returned ${String(response.status)}.` + ); + } + return parseSettings(await response.json()); +} + +export async function saveSettings( + settings: SimpleSyrupSettings, + fetchImpl: FetchLike = fetch +): Promise { + const response = await fetchImpl(SETTINGS_ROUTE, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings) + }); + if (!response.ok) { + throw new Error( + `Could not save SimpleSyrup settings. Backend returned ${String(response.status)}.` + ); + } + return parseSettings(await response.json()); +} + +export function parseSettings(payload: unknown): SimpleSyrupSettings { + if (!isSettingsPayload(payload)) { + throw new Error( + "SimpleSyrup settings payload is invalid. Expected show_downloadable_models to be a boolean." + ); + } + return { + show_downloadable_models: payload.show_downloadable_models + }; +} + +function isSettingsPayload(payload: unknown): payload is SimpleSyrupSettings { + return ( + typeof payload === "object" && + payload !== null && + typeof (payload as Partial).show_downloadable_models === + "boolean" + ); +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..18e9591 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,18 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +// @ts-expect-error ComfyUI serves this host module outside the extension package. +import { app } from "../../../scripts/app.js"; + +import { registerSimpleSyrupSettings } from "./settings"; +import type { ComfyApp } from "./types"; + +const comfyApp = app as unknown as ComfyApp; + +comfyApp.registerExtension({ + name: "SimpleSyrup.Settings", + async setup(appInstance) { + await registerSimpleSyrupSettings(appInstance); + } +}); diff --git a/web/src/settings.ts b/web/src/settings.ts new file mode 100644 index 0000000..f62fcb5 --- /dev/null +++ b/web/src/settings.ts @@ -0,0 +1,65 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { getSettings, saveSettings } from "./api"; +import type { SimpleSyrupSettings } from "./api"; +import type { ComfyApp, Logger } from "./types"; + +export const SIMPLE_SYRUP_SETTING_ID = "SimpleSyrup.ShowDownloadableModels"; +export const SIMPLE_SYRUP_SETTING_LABEL = + "SimpleSyrup: Show downloadable models in loader dropdowns"; +export const SIMPLE_SYRUP_SETTING_DESCRIPTION = + "Show known downloadable SAM, GroundingDINO, and ViTMatte models even when they are not installed locally."; + +const DEFAULT_SETTINGS: SimpleSyrupSettings = { + show_downloadable_models: true +}; + +export interface SimpleSyrupSettingsApi { + getSettings(): Promise; + saveSettings(settings: SimpleSyrupSettings): Promise; +} + +export async function registerSimpleSyrupSettings( + app: ComfyApp, + api: SimpleSyrupSettingsApi = { getSettings, saveSettings }, + logger: Logger = console +): Promise { + let initialSettings = DEFAULT_SETTINGS; + + try { + initialSettings = await api.getSettings(); + } catch (error) { + logger.warn( + "Could not load SimpleSyrup settings. Using the default setting until the backend is available.", + error + ); + } + let savedSettings = initialSettings; + + const setting = app.ui.settings.addSetting({ + id: SIMPLE_SYRUP_SETTING_ID, + name: SIMPLE_SYRUP_SETTING_LABEL, + type: "boolean", + defaultValue: initialSettings.show_downloadable_models, + tooltip: SIMPLE_SYRUP_SETTING_DESCRIPTION, + onChange: async (value: boolean) => { + try { + const saved = await api.saveSettings({ + show_downloadable_models: value + }); + savedSettings = saved; + setting.value = saved.show_downloadable_models; + } catch (error) { + logger.warn( + "Could not save SimpleSyrup settings. The backend rejected the setting update.", + error + ); + setting.value = savedSettings.show_downloadable_models; + } + } + }); + + setting.value = initialSettings.show_downloadable_models; +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..abcbf01 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,38 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +export type Logger = Pick; + +export type SettingValue = boolean | string | number | null; + +export interface ComfySetting { + value: TValue; +} + +export interface ComfySettingDefinition { + id: string; + name: string; + type: "boolean"; + defaultValue: TValue; + tooltip?: string; + onChange?: (value: TValue) => void | Promise; +} + +export interface ComfySettingsApi { + addSetting( + definition: ComfySettingDefinition + ): ComfySetting; +} + +export interface ComfyApp { + ui: { + settings: ComfySettingsApi; + }; + registerExtension(extension: ComfyExtension): void; +} + +export interface ComfyExtension { + name: string; + setup(app: ComfyApp): void | Promise; +} diff --git a/web/tests/api.test.ts b/web/tests/api.test.ts new file mode 100644 index 0000000..899a2c5 --- /dev/null +++ b/web/tests/api.test.ts @@ -0,0 +1,66 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from "vitest"; + +import { getSettings, parseSettings, saveSettings } from "../src/api"; +import type { FetchLike } from "../src/api"; +import { createJsonResponse } from "./testUtils"; + +describe("settings API", () => { + it("loads SimpleSyrup settings from the backend route", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + createJsonResponse({ show_downloadable_models: false }) + ); + + await expect(getSettings(fetchImpl)).resolves.toEqual({ + show_downloadable_models: false + }); + expect(fetchImpl).toHaveBeenCalledWith("/simple-syrup/settings"); + }); + + it("saves SimpleSyrup settings to the backend route", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + createJsonResponse({ show_downloadable_models: true }) + ); + + await expect( + saveSettings({ show_downloadable_models: true }, fetchImpl) + ).resolves.toEqual({ show_downloadable_models: true }); + expect(fetchImpl).toHaveBeenCalledWith( + "/simple-syrup/settings", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ show_downloadable_models: true }) + }) + ); + }); + + it("rejects non-OK load responses with an actionable error", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(createJsonResponse({ error: "nope" }, { status: 500 })); + + await expect(getSettings(fetchImpl)).rejects.toThrow( + "Could not load SimpleSyrup settings" + ); + }); + + it("rejects non-OK save responses with an actionable error", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(createJsonResponse({ error: "nope" }, { status: 400 })); + + await expect( + saveSettings({ show_downloadable_models: false }, fetchImpl) + ).rejects.toThrow("Could not save SimpleSyrup settings"); + }); + + it("rejects invalid response payloads conservatively", () => { + expect(() => parseSettings({ show_downloadable_models: "false" })).toThrow( + "SimpleSyrup settings payload is invalid" + ); + }); +}); diff --git a/web/tests/settings.test.ts b/web/tests/settings.test.ts new file mode 100644 index 0000000..16266a3 --- /dev/null +++ b/web/tests/settings.test.ts @@ -0,0 +1,101 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from "vitest"; + +import { + SIMPLE_SYRUP_SETTING_ID, + SIMPLE_SYRUP_SETTING_LABEL, + registerSimpleSyrupSettings +} from "../src/settings"; +import type { SimpleSyrupSettingsApi } from "../src/settings"; +import { createFakeComfyApp } from "./testUtils"; + +describe("Comfy settings registration", () => { + it("registers the ShowDownloadableModels setting from backend state", async () => { + const app = createFakeComfyApp(); + const api = fakeSettingsApi(false); + + await registerSimpleSyrupSettings(app, api); + + expect(app.ui.settings.definitions).toHaveLength(1); + expect(app.ui.settings.definitions[0]).toMatchObject({ + id: SIMPLE_SYRUP_SETTING_ID, + name: SIMPLE_SYRUP_SETTING_LABEL, + type: "boolean", + defaultValue: false + }); + expect(app.ui.settings.settings[0]?.value).toBe(false); + }); + + it("saves setting changes to the backend", async () => { + const app = createFakeComfyApp(); + const saveSettings = vi + .fn() + .mockResolvedValue({ show_downloadable_models: true }); + const api: SimpleSyrupSettingsApi = { + getSettings: vi.fn().mockResolvedValue({ show_downloadable_models: false }), + saveSettings + }; + + await registerSimpleSyrupSettings(app, api); + await app.ui.settings.definitions[0]?.onChange?.(true); + + expect(saveSettings).toHaveBeenCalledWith({ + show_downloadable_models: true + }); + expect(app.ui.settings.settings[0]?.value).toBe(true); + }); + + it("falls back to the default and warns when backend load fails", async () => { + const app = createFakeComfyApp(); + const logger = { warn: vi.fn() }; + const api: SimpleSyrupSettingsApi = { + getSettings: vi.fn().mockRejectedValue(new Error("offline")), + saveSettings: vi.fn().mockResolvedValue({ show_downloadable_models: true }) + }; + + await registerSimpleSyrupSettings(app, api, logger); + + expect(app.ui.settings.settings[0]?.value).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Could not load SimpleSyrup settings"), + expect.any(Error) + ); + }); + + it("warns and restores the previous value when backend save fails", async () => { + const app = createFakeComfyApp(); + const logger = { warn: vi.fn() }; + const saveSettings = vi + .fn() + .mockResolvedValueOnce({ show_downloadable_models: true }) + .mockRejectedValueOnce(new Error("rejected")); + const api: SimpleSyrupSettingsApi = { + getSettings: vi.fn().mockResolvedValue({ show_downloadable_models: false }), + saveSettings + }; + + await registerSimpleSyrupSettings(app, api, logger); + await app.ui.settings.definitions[0]?.onChange?.(true); + await app.ui.settings.definitions[0]?.onChange?.(false); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Could not save SimpleSyrup settings"), + expect.any(Error) + ); + expect(app.ui.settings.settings[0]?.value).toBe(true); + }); +}); + +function fakeSettingsApi( + showDownloadableModels: boolean +): SimpleSyrupSettingsApi { + return { + getSettings: vi.fn().mockResolvedValue({ + show_downloadable_models: showDownloadableModels + }), + saveSettings: vi.fn().mockImplementation((settings) => Promise.resolve(settings)) + }; +} diff --git a/web/tests/testUtils.ts b/web/tests/testUtils.ts new file mode 100644 index 0000000..d8673d2 --- /dev/null +++ b/web/tests/testUtils.ts @@ -0,0 +1,65 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { + ComfyApp, + ComfyExtension, + ComfySetting, + ComfySettingDefinition, + SettingValue +} from "../src/types"; + +export interface FakeComfySettingsApi { + definitions: Array>; + settings: Array>; + addSetting( + definition: ComfySettingDefinition + ): ComfySetting; +} + +export interface FakeComfyApp extends ComfyApp { + extensions: ComfyExtension[]; + ui: { + settings: FakeComfySettingsApi; + }; +} + +export function createFakeComfyApp(): FakeComfyApp { + const settingsApi: FakeComfySettingsApi = { + definitions: [], + settings: [], + addSetting( + definition: ComfySettingDefinition + ): ComfySetting { + const setting: ComfySetting = { + value: definition.defaultValue + }; + this.definitions.push( + definition as unknown as ComfySettingDefinition + ); + this.settings.push(setting as ComfySetting); + return setting; + } + }; + + return { + extensions: [], + ui: { + settings: settingsApi + }, + registerExtension(extension: ComfyExtension): void { + this.extensions.push(extension); + } + }; +} + +export function createJsonResponse( + payload: unknown, + init: ResponseInit = {} +): Response { + return new Response(JSON.stringify(payload), { + headers: { "Content-Type": "application/json" }, + ...init + }); +} From 7496820988115f8b96e76767a2c9eccde840f47b Mon Sep 17 00:00:00 2001 From: Daisy Date: Fri, 22 May 2026 19:45:24 +0000 Subject: [PATCH 02/40] chore(release): 1.0.0 [skip ci] # 1.0.0 (2026-05-22) ### Features * initial release ([e513baf](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/e513baf70a20306856e40fbf2afd80b25f5655a6)) --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- pyproject.toml | 2 +- simple_syrup/__init__.py | 2 +- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6361e43..47430e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 1.0.0 (2026-05-22) + + +### Features + +* initial release ([e513baf](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/e513baf70a20306856e40fbf2afd80b25f5655a6)) + # Changelog All notable changes to this project will be documented in this file. diff --git a/package-lock.json b/package-lock.json index cf9ddcd..206dbb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-syrup-comfyui", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-syrup-comfyui", - "version": "0.1.0", + "version": "1.0.0", "license": "AGPL-3.0-or-later", "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/package.json b/package.json index bd63729..47f2869 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-syrup-comfyui", - "version": "0.1.0", + "version": "1.0.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index b6f9234..f7a5bf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "simple-syrup" description = "Workflow-focused ComfyUI extensions for image generation." -version = "0.1.0" +version = "1.0.0" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] requires-python = ">=3.11" diff --git a/simple_syrup/__init__.py b/simple_syrup/__init__.py index d69e974..d802de7 100644 --- a/simple_syrup/__init__.py +++ b/simple_syrup/__init__.py @@ -6,6 +6,6 @@ from __future__ import annotations -__version__ = "0.1.0" +__version__ = "1.0.0" __all__: list[str] = ["__version__"] From 1ede870cf56d0b0aa01c96c3ef291dad6c428935 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Fri, 22 May 2026 21:42:04 -0400 Subject: [PATCH 03/40] fix(detailers): align SEGS mask blending behavior --- simple_syrup/masking/detailer_masks.py | 96 ++++++++++ .../masking/regional_detailing_masks.py | 3 +- simple_syrup/masking/segs_mask_ops.py | 56 ++++-- .../nodes/detect_segs_with_ultralytics.py | 8 +- simple_syrup/nodes/prompt_segs_with_sam.py | 8 +- simple_syrup/runtime/ultralytics_detection.py | 36 +++- .../detail_segs_by_scale_factor_service.py | 18 +- ...by_scale_factor_tiled_diffusion_service.py | 18 +- simple_syrup/services/segs_output_service.py | 30 ++- tests/test_detail_masks.py | 71 ++++++++ tests/test_detail_segs_as_regions_service.py | 22 +++ ...est_detail_segs_by_scale_factor_service.py | 64 ++++++- ...by_scale_factor_tiled_diffusion_service.py | 54 +++++- .../test_detect_segs_with_ultralytics_node.py | 12 +- tests/test_prompt_segs_with_sam_node.py | 4 + tests/test_prompt_segs_with_sam_service.py | 2 +- tests/test_ultralytics_detection_service.py | 172 ++++++++++++++++-- 17 files changed, 595 insertions(+), 79 deletions(-) create mode 100644 simple_syrup/masking/detailer_masks.py create mode 100644 tests/test_detail_masks.py diff --git a/simple_syrup/masking/detailer_masks.py b/simple_syrup/masking/detailer_masks.py new file mode 100644 index 0000000..64f8a7b --- /dev/null +++ b/simple_syrup/masking/detailer_masks.py @@ -0,0 +1,96 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Detailer mask preparation helpers.""" + +from __future__ import annotations + +import torch +import torch.nn.functional as F + +DETAILER_GAUSSIAN_SIGMA = 10.0 + + +def gaussian_feather_mask(mask: torch.Tensor, radius: int) -> torch.Tensor: + """Soften a detailer mask edge with a Gaussian blur.""" + + if not isinstance(mask, torch.Tensor): + raise TypeError("detailer mask must be a torch.Tensor.") + if radius < 0: + raise ValueError("detailer mask radius must be greater than or equal to 0.") + if mask.ndim not in {2, 3}: + raise ValueError("detailer mask must be HW or BHW shaped.") + + working = mask.float().clamp(0.0, 1.0) + if radius == 0 or _has_no_internal_boundary(working): + return working + + was_hw = working.ndim == 2 + batched = working.unsqueeze(0) if was_hw else working + kernel_size = _effective_kernel_size( + radius, + height=int(batched.shape[-2]), + width=int(batched.shape[-1]), + ) + if kernel_size == 0: + return working + effective_radius = kernel_size // 2 + samples = batched.unsqueeze(1) + kernel = _gaussian_kernel( + effective_radius, + device=samples.device, + dtype=samples.dtype, + ) + padded_horizontal = F.pad( + samples, + (effective_radius, effective_radius, 0, 0), + mode="replicate", + ) + blurred = F.conv2d(padded_horizontal, kernel.view(1, 1, 1, -1)) + padded_vertical = F.pad( + blurred, + (0, 0, effective_radius, effective_radius), + mode="replicate", + ) + blurred = F.conv2d(padded_vertical, kernel.view(1, 1, -1, 1)) + result = blurred.squeeze(1).clamp(0.0, 1.0) + return result.squeeze(0) if was_hw else result + + +def _has_no_internal_boundary(mask: torch.Tensor) -> bool: + """Return whether every mask value is identical.""" + + return bool(torch.all(mask == mask.flatten()[0]).item()) + + +def _effective_kernel_size(radius: int, *, height: int, width: int) -> int: + """Return a usable odd blur kernel size for a mask.""" + + kernel_size = radius * 2 + 1 + shortest = min(height, width) + if shortest <= kernel_size: + kernel_size = int(shortest / 2) + if kernel_size % 2 == 0: + kernel_size += 1 + if kernel_size < 3: + return 0 + return kernel_size + + +def _gaussian_kernel( + radius: int, + *, + device: torch.device, + dtype: torch.dtype, +) -> torch.Tensor: + """Return a normalized one-dimensional Gaussian kernel.""" + + positions = torch.arange( + -radius, + radius + 1, + device=device, + dtype=dtype, + ) + kernel = torch.exp(-(positions * positions) / (2.0 * DETAILER_GAUSSIAN_SIGMA**2)) + return kernel / kernel.sum() diff --git a/simple_syrup/masking/regional_detailing_masks.py b/simple_syrup/masking/regional_detailing_masks.py index b7cc86f..e8e5071 100644 --- a/simple_syrup/masking/regional_detailing_masks.py +++ b/simple_syrup/masking/regional_detailing_masks.py @@ -21,6 +21,7 @@ from ..domain.regional_detailing import ( SegmentConditioningPair, ) from ..domain.segs import CropRegion +from .detailer_masks import gaussian_feather_mask from .segs_mask_ops import feather_mask, resize_mask OPERATION = "Detail SEGS as Regions" @@ -153,7 +154,7 @@ def union_masks(masks: tuple[torch.Tensor, ...]) -> torch.Tensor: def feather_image_mask(mask: torch.Tensor, feather: int) -> torch.Tensor: """Feather an image-space mask while preserving the HW contract.""" - return feather_mask(mask, feather) + return gaussian_feather_mask(mask, feather) def proportional_latent_box( diff --git a/simple_syrup/masking/segs_mask_ops.py b/simple_syrup/masking/segs_mask_ops.py index e495527..07253ea 100644 --- a/simple_syrup/masking/segs_mask_ops.py +++ b/simple_syrup/masking/segs_mask_ops.py @@ -80,16 +80,19 @@ def normalize_mask(mask: torch.Tensor, height: int, width: int) -> torch.Tensor: def dilate_mask(mask: torch.Tensor, dilation: int) -> torch.Tensor: - """Morph an HW mask by a signed pixel radius.""" + """Morph an HW mask by a signed kernel-size factor.""" if dilation == 0: return mask.float().clamp(0.0, 1.0) - radius = abs(dilation) - kernel_size = radius * 2 + 1 + kernel_size = abs(dilation) + if kernel_size < 1: + return mask.float().clamp(0.0, 1.0) + pad_before = kernel_size // 2 + pad_after = kernel_size - 1 - pad_before padded = F.pad( mask.float().unsqueeze(0).unsqueeze(0), - (radius, radius, radius, radius), - value=0.0, + (pad_before, pad_after, pad_before, pad_after), + value=0.0 if dilation > 0 else 1.0, ) if dilation > 0: return ( @@ -138,21 +141,44 @@ def crop_region_for_bbox( return CropRegion(0, 0, image_width, image_height) if crop_factor < 1.0: raise ValueError("crop_factor must be 0 or greater than or equal to 1.0.") - center_x = (bbox.left + bbox.right) / 2.0 - center_y = (bbox.top + bbox.bottom) / 2.0 crop_width = bbox.width * crop_factor crop_height = bbox.height * crop_factor - left = max(0, int(round(center_x - crop_width / 2.0))) - top = max(0, int(round(center_y - crop_height / 2.0))) - right = min(image_width, int(round(center_x + crop_width / 2.0))) - bottom = min(image_height, int(round(center_y + crop_height / 2.0))) - if right <= left: - right = min(image_width, left + 1) - if bottom <= top: - bottom = min(image_height, top + 1) + center_x = bbox.left + bbox.width / 2.0 + center_y = bbox.top + bbox.height / 2.0 + left, right = _normalize_region( + image_width, + int(center_x - crop_width / 2.0), + crop_width, + ) + top, bottom = _normalize_region( + image_height, + int(center_y - crop_height / 2.0), + crop_height, + ) return CropRegion(left, top, right, bottom) +def _normalize_region(limit: int, start: int, size: float) -> tuple[int, int]: + """Shift a crop into bounds while preserving requested size when possible.""" + + new_start: float + new_end: float + if start < 0: + new_start = 0 + new_end = min(limit, size) + elif start + size > limit: + new_start = max(0, limit - size) + new_end = limit + else: + new_start = start + new_end = min(limit, start + size) + left = int(new_start) + right = int(new_end) + if right <= left: + right = min(limit, left + 1) + return left, right + + def crop_image(image: torch.Tensor, region: CropRegion) -> torch.Tensor: """Crop a BHWC image tensor by a crop region.""" diff --git a/simple_syrup/nodes/detect_segs_with_ultralytics.py b/simple_syrup/nodes/detect_segs_with_ultralytics.py index ffb4be0..6ffdb2c 100644 --- a/simple_syrup/nodes/detect_segs_with_ultralytics.py +++ b/simple_syrup/nodes/detect_segs_with_ultralytics.py @@ -47,9 +47,9 @@ class DetectSEGSWithUltralytics: SEARCH_ALIASES = ["ultralytics", "yolo", "segs", "detector"] service_class: ClassVar[type[SegsDetectionService]] = SegsDetectionService - combined_builder: ClassVar[Callable[[object, NativeSegs], CombinedSegsResult]] = ( - build_combined_segs_result - ) + combined_builder: ClassVar[ + Callable[[object, NativeSegs, float], CombinedSegsResult] + ] = build_combined_segs_result @classmethod def INPUT_TYPES(cls) -> dict[str, dict[str, Any]]: @@ -205,7 +205,7 @@ class DetectSEGSWithUltralytics: post_dilation=post_dilation, ) segs = sort_segs(segs, sort_order) - combined = type(self).combined_builder(single_image, segs) + 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) diff --git a/simple_syrup/nodes/prompt_segs_with_sam.py b/simple_syrup/nodes/prompt_segs_with_sam.py index bdf3f40..79d4227 100644 --- a/simple_syrup/nodes/prompt_segs_with_sam.py +++ b/simple_syrup/nodes/prompt_segs_with_sam.py @@ -45,9 +45,9 @@ class PromptSEGSWithSAM: ) SEARCH_ALIASES = ["sam", "groundingdino", "prompt", "segs"] - combined_builder: ClassVar[Callable[[object, NativeSegs], CombinedSegsResult]] = ( - build_combined_segs_result - ) + combined_builder: ClassVar[ + Callable[[object, NativeSegs, float], CombinedSegsResult] + ] = build_combined_segs_result @classmethod def INPUT_TYPES(cls) -> dict[str, dict[str, Any]]: @@ -333,7 +333,7 @@ class PromptSEGSWithSAM: crop_factor=crop_factor, sort_order=sort_order, ) - combined = type(self).combined_builder(single_image, segs) + 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) diff --git a/simple_syrup/runtime/ultralytics_detection.py b/simple_syrup/runtime/ultralytics_detection.py index 8e7ab37..3e1e811 100644 --- a/simple_syrup/runtime/ultralytics_detection.py +++ b/simple_syrup/runtime/ultralytics_detection.py @@ -144,7 +144,41 @@ def _result_masks( tensor = tensor.unsqueeze(0) if tensor.ndim != 3: raise ValueError("Ultralytics masks must be shaped as BHW.") - return [normalize_mask(mask, image_height, image_width) for mask in tensor] + return [ + normalize_mask( + _remove_aspect_padding(mask, image_height, image_width), + image_height, + image_width, + ) + for mask in tensor + ] + + +def _remove_aspect_padding( + mask: torch.Tensor, + image_height: int, + image_width: int, +) -> torch.Tensor: + """Remove Ultralytics mask padding before resizing to image geometry.""" + + mask_height = int(mask.shape[0]) + mask_width = int(mask.shape[1]) + mask_ratio = mask_height / mask_width + image_ratio = image_height / image_width + if mask_ratio == image_ratio: + return mask + if mask_ratio > image_ratio: + height_gap = int((mask_ratio - image_ratio) * mask_height) + if height_gap <= 0 or height_gap * 2 >= mask_height: + return mask + return mask[height_gap : mask_height - height_gap, :] + + width_ratio = mask_width / mask_height + image_width_ratio = image_width / image_height + width_gap = int((width_ratio - image_width_ratio) * mask_width) + if width_gap <= 0 or width_gap * 2 >= mask_width: + return mask + return mask[:, width_gap : mask_width - width_gap] def _result_names(result: object, fallback: dict[int, str]) -> dict[int, str]: diff --git a/simple_syrup/services/detail_segs_by_scale_factor_service.py b/simple_syrup/services/detail_segs_by_scale_factor_service.py index 4e23d02..0c622af 100644 --- a/simple_syrup/services/detail_segs_by_scale_factor_service.py +++ b/simple_syrup/services/detail_segs_by_scale_factor_service.py @@ -15,10 +15,9 @@ from ..domain.conditioning_batch import select_conditioning from ..domain.detail_geometry import DetailScalePlan, build_detail_scale_plan from ..domain.segs import Segment, coerce_segs from ..image.crop_composite import composite_crop +from ..masking.detailer_masks import gaussian_feather_mask from ..masking.segs_mask_ops import ( crop_image, - feather_mask, - resize_mask, validate_single_image, ) from ..runtime.detail_previews import DetailPreviewContext @@ -232,10 +231,9 @@ class DetailSEGSByScaleFactorService: plan.width, upscale_method, ) - scaled_mask = resize_mask(mask_crop, plan.height, plan.width) latent = self._sampler.encode(vae, scaled_crop, tiled_encode) if noise_mask: - latent = self._with_noise_mask(latent, scaled_mask, noise_mask_feather) + latent = self._with_noise_mask(latent, mask_crop, noise_mask_feather) sampled = self._sampler.sample( model=model, seed=seed, @@ -259,7 +257,9 @@ class DetailSEGSByScaleFactorService: segment.crop_region.height, segment.crop_region.width, ) - paste_mask = feather_mask(mask_crop, feather).to(device=working_image.device) + paste_mask = gaussian_feather_mask(mask_crop, feather).to( + device=working_image.device + ) return composite_crop( image=working_image, crop=resized_detail, @@ -273,16 +273,14 @@ class DetailSEGSByScaleFactorService: mask: torch.Tensor, noise_mask_feather: int, ) -> Latent: - """Attach a latent-sized denoise mask to a latent dictionary.""" + """Attach a crop-space denoise mask to a latent dictionary.""" samples = latent.get("samples") if not isinstance(samples, torch.Tensor): raise ValueError("latent samples must be a torch tensor.") - latent_height = int(samples.shape[-2]) - latent_width = int(samples.shape[-1]) - latent_mask = resize_mask(mask, latent_height, latent_width) + latent_mask = mask if noise_mask_feather > 0: - latent_mask = feather_mask(latent_mask, noise_mask_feather) + latent_mask = gaussian_feather_mask(latent_mask, noise_mask_feather) output = latent.copy() output["noise_mask"] = latent_mask.unsqueeze(0).to( device=samples.device, diff --git a/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py b/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py index 150c22d..673ef87 100644 --- a/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py +++ b/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py @@ -16,10 +16,9 @@ from ..domain.detail_geometry import DetailScalePlan, build_detail_scale_plan from ..domain.segs import Segment, coerce_segs from ..domain.tiled_diffusion import validate_tiled_diffusion_mode from ..image.crop_composite import composite_crop +from ..masking.detailer_masks import gaussian_feather_mask from ..masking.segs_mask_ops import ( crop_image, - feather_mask, - resize_mask, validate_single_image, ) from ..runtime.detail_previews import DetailPreviewContext @@ -362,10 +361,9 @@ class DetailSEGSByScaleFactorTiledDiffusionService: plan.width, upscale_method, ) - scaled_mask = resize_mask(mask_crop, plan.height, plan.width) latent = self._sampler.encode(vae, scaled_crop, tiled_encode) if noise_mask: - latent = self._with_noise_mask(latent, scaled_mask, noise_mask_feather) + latent = self._with_noise_mask(latent, mask_crop, noise_mask_feather) sampled = self._sampler.sample_tiled( diffusion_mode=diffusion_mode, model=model, @@ -394,7 +392,9 @@ class DetailSEGSByScaleFactorTiledDiffusionService: segment.crop_region.height, segment.crop_region.width, ) - paste_mask = feather_mask(mask_crop, feather).to(device=working_image.device) + paste_mask = gaussian_feather_mask(mask_crop, feather).to( + device=working_image.device + ) return composite_crop( image=working_image, crop=resized_detail, @@ -408,16 +408,14 @@ class DetailSEGSByScaleFactorTiledDiffusionService: mask: torch.Tensor, noise_mask_feather: int, ) -> Latent: - """Attach a latent-sized denoise mask to a latent dictionary.""" + """Attach a crop-space denoise mask to a latent dictionary.""" samples = latent.get("samples") if not isinstance(samples, torch.Tensor): raise ValueError("latent samples must be a torch tensor.") - latent_height = int(samples.shape[-2]) - latent_width = int(samples.shape[-1]) - latent_mask = resize_mask(mask, latent_height, latent_width) + latent_mask = mask if noise_mask_feather > 0: - latent_mask = feather_mask(latent_mask, noise_mask_feather) + latent_mask = gaussian_feather_mask(latent_mask, noise_mask_feather) output = latent.copy() output["noise_mask"] = latent_mask.unsqueeze(0).to( device=samples.device, diff --git a/simple_syrup/services/segs_output_service.py b/simple_syrup/services/segs_output_service.py index 584d621..0840805 100644 --- a/simple_syrup/services/segs_output_service.py +++ b/simple_syrup/services/segs_output_service.py @@ -10,8 +10,13 @@ from dataclasses import dataclass import torch -from ..domain.segs import BoundingBox, CropRegion, NativeSegs, Segment -from ..masking.segs_mask_ops import crop_image, crop_mask, validate_single_image +from ..domain.segs import BoundingBox, NativeSegs, Segment +from ..masking.segs_mask_ops import ( + crop_image, + crop_mask, + crop_region_for_bbox, + validate_single_image, +) @dataclass(frozen=True) @@ -25,6 +30,7 @@ class CombinedSegsResult: def build_combined_segs_result( image: object, segs: NativeSegs, + crop_factor: float, ) -> CombinedSegsResult: """Build one combined SEGS payload and a standard ComfyUI mask.""" @@ -37,21 +43,27 @@ def build_combined_segs_result( return CombinedSegsResult(segs=(header, ()), mask=mask_output) y_coords, x_coords = torch.where(combined_mask > 0) - left = int(torch.min(x_coords).item()) - top = int(torch.min(y_coords).item()) - right = min(width, int(torch.max(x_coords).item()) + 1) - bottom = min(height, int(torch.max(y_coords).item()) + 1) - if right <= left or bottom <= top: + bbox_left = int(torch.min(x_coords).item()) + bbox_top = int(torch.min(y_coords).item()) + bbox_right = min(width, int(torch.max(x_coords).item()) + 1) + bbox_bottom = min(height, int(torch.max(y_coords).item()) + 1) + if bbox_right <= bbox_left or bbox_bottom <= bbox_top: return CombinedSegsResult(segs=(header, ()), mask=mask_output) - crop_region = CropRegion(left, top, right, bottom) + bbox = BoundingBox(bbox_left, bbox_top, bbox_right, bbox_bottom) + crop_region = crop_region_for_bbox( + bbox, + image_height=height, + image_width=width, + crop_factor=crop_factor, + ) cropped_mask = crop_mask(combined_mask, crop_region).detach().clone() combined_segment = Segment( cropped_image=crop_image(image_tensor, crop_region).detach().clone(), cropped_mask=cropped_mask, confidence=max(segment.confidence for segment in segments), crop_region=crop_region, - bbox=BoundingBox(left, top, right, bottom), + bbox=bbox, label="combined", ) return CombinedSegsResult(segs=(header, (combined_segment,)), mask=mask_output) diff --git a/tests/test_detail_masks.py b/tests/test_detail_masks.py new file mode 100644 index 0000000..f58956a --- /dev/null +++ b/tests/test_detail_masks.py @@ -0,0 +1,71 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for detailer-specific mask feathering.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.masking.detailer_masks import gaussian_feather_mask + + +def test_gaussian_feather_preserves_empty_and_full_masks() -> None: + """Masks without an internal boundary remain stable after feathering.""" + + empty = torch.zeros((6, 6), dtype=torch.float32) + full = torch.ones((6, 6), dtype=torch.float32) + + assert torch.equal(gaussian_feather_mask(empty, 3), empty) + assert torch.equal(gaussian_feather_mask(full, 3), full) + + +def test_gaussian_feather_softens_rectangular_mask_with_padding() -> None: + """A padded rectangle gains a smooth alpha ramp around its boundary.""" + + mask = torch.zeros((9, 9), dtype=torch.float32) + mask[3:6, 3:6] = 1.0 + + feathered = gaussian_feather_mask(mask, 2) + + assert feathered.shape == mask.shape + assert 0.0 < float(feathered[2, 4]) < 1.0 + assert 0.0 < float(feathered[4, 2]) < 1.0 + assert float(feathered[4, 4]) > float(feathered[2, 4]) + assert float(feathered[0, 0]) < float(feathered[2, 4]) + + +def test_gaussian_feather_preserves_bhw_shape_and_range() -> None: + """Batched masks keep their shape and stay in normalized alpha range.""" + + mask = torch.zeros((2, 7, 7), dtype=torch.float32) + mask[:, 2:5, 2:5] = 1.0 + + feathered = gaussian_feather_mask(mask, 2) + + assert feathered.shape == mask.shape + assert float(feathered.min()) >= 0.0 + assert float(feathered.max()) <= 1.0 + assert 0.0 < float(feathered[0, 1, 3]) < 1.0 + + +def test_gaussian_feather_skips_too_small_masks() -> None: + """Masks too small for a useful blur are returned unchanged.""" + + mask = torch.zeros((2, 2), dtype=torch.float32) + mask[0, 0] = 1.0 + + assert torch.equal(gaussian_feather_mask(mask, 3), mask) + + +def test_gaussian_feather_rejects_invalid_inputs() -> None: + """Invalid mask inputs fail before producing misleading alpha masks.""" + + with pytest.raises(TypeError, match="torch.Tensor"): + gaussian_feather_mask([[1.0]], 1) # type: ignore[arg-type] + with pytest.raises(ValueError, match="HW or BHW"): + gaussian_feather_mask(torch.zeros((1, 1, 2, 2)), 1) + with pytest.raises(ValueError, match="greater than or equal to 0"): + gaussian_feather_mask(torch.zeros((2, 2)), -1) diff --git a/tests/test_detail_segs_as_regions_service.py b/tests/test_detail_segs_as_regions_service.py index 3a5edb7..dfb5cff 100644 --- a/tests/test_detail_segs_as_regions_service.py +++ b/tests/test_detail_segs_as_regions_service.py @@ -137,6 +137,28 @@ def test_decoded_output_is_composited_only_inside_union_mask() -> None: assert torch.all(result.image[:, :, 4:, :] == 0.0) +def test_regional_composite_uses_gaussian_feathered_union_mask() -> None: + """Final regional compositing uses a softened full-image union mask.""" + + sampler = _FakeRegionalSampler() + service = _service(sampler) + + result = service.detail( + _image(), + _segs(_segment(CropRegion(0, 0, 4, 4))), + "model", + "vae", + "global positive", + "negative", + ConditioningBatch(("regional positive",)), + **(_settings() | {"feather": 1}), + ) + + assert float(result.image[0, 1, 1, 0]) > float(result.image[0, 3, 2, 0]) + assert 0.0 < float(result.image[0, 3, 2, 0]) < 1.0 + assert float(result.image[0, 5, 5, 0]) < float(result.image[0, 3, 2, 0]) + + def test_scale_factor_resizes_working_canvas_and_preserves_outside_mask() -> None: """Scaled regional detailing keeps original pixels outside the union mask.""" diff --git a/tests/test_detail_segs_by_scale_factor_service.py b/tests/test_detail_segs_by_scale_factor_service.py index b6004ee..f92b8f6 100644 --- a/tests/test_detail_segs_by_scale_factor_service.py +++ b/tests/test_detail_segs_by_scale_factor_service.py @@ -309,7 +309,7 @@ def test_downscale_factors_sample_at_original_crop_size() -> None: def test_noise_mask_true_attaches_latent_mask() -> None: - """Noise-mask mode passes a latent mask to sampling.""" + """Noise-mask mode passes the crop-local mask to sampling.""" sampler = _FakeSampler() service = _service(sampler) @@ -361,6 +361,55 @@ def test_non_tensor_cropped_mask_is_used_as_crop() -> None: assert sampler.sample_calls[0].noise_mask_shape == (1, 4, 4) +def test_paste_mask_uses_gaussian_feathered_crop_mask() -> None: + """Final compositing uses a softened crop-local detailer mask.""" + + sampler = _FakeSampler() + service = _service(sampler) + mask = torch.zeros((4, 4), dtype=torch.float32) + mask[1:3, 1:3] = 1.0 + + result = service.detail( + _image(), + _segs(_segment(cropped_mask=mask)), + object(), + object(), + [], + [], + **(_settings() | {"scale_factor": 1.0, "feather": 1}), + ) + + crop = result.image[:, 2:6, 2:6, 0] + assert 0.0 < float(crop[0, 0, 1]) < 1.0 + assert float(crop[0, 1, 1]) > float(crop[0, 0, 1]) + assert float(crop[0, 3, 3]) < float(crop[0, 1, 1]) + + +def test_noise_mask_feather_is_applied_before_latent_resize() -> None: + """Noise-mask feathering keeps original crop geometry for ComfyUI resizing.""" + + sampler = _FakeSampler() + service = _service(sampler) + mask = torch.zeros((4, 4), dtype=torch.float32) + mask[1:3, 1:3] = 1.0 + + service.detail( + _image(), + _segs(_segment(cropped_mask=mask)), + object(), + object(), + [], + [], + **(_settings() | {"noise_mask_feather": 1}), + ) + + noise_mask = sampler.sample_calls[0].noise_mask + assert noise_mask is not None + assert noise_mask.shape == (1, 4, 4) + assert 0.0 < float(noise_mask[0, 0, 1]) < 1.0 + assert float(noise_mask[0, 1, 1]) > float(noise_mask[0, 0, 1]) + + def test_noise_mask_feather_applies_model_patch() -> None: """Feathered noise masks request differential diffusion patching.""" @@ -404,6 +453,7 @@ class _SampleCall: self, seed: int, noise_mask_shape: tuple[int, ...] | None, + noise_mask: torch.Tensor | None, positive: Any, negative: Any, preview_context: DetailPreviewContext | None, @@ -412,6 +462,7 @@ class _SampleCall: self.seed = seed self.noise_mask_shape = noise_mask_shape + self.noise_mask = noise_mask self.positive = positive self.negative = negative self.preview_context = preview_context @@ -475,7 +526,16 @@ class _FakeSampler: else None ) self.sample_calls.append( - _SampleCall(seed, noise_mask_shape, positive, negative, preview_context) + _SampleCall( + seed, + noise_mask_shape, + noise_mask.detach().clone() + if isinstance(noise_mask, torch.Tensor) + else None, + positive, + negative, + preview_context, + ) ) return latent_image diff --git a/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py b/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py index 78ab37b..b73b63a 100644 --- a/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py +++ b/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py @@ -152,7 +152,7 @@ def test_tiled_detailer_forwards_detail_preview_context() -> None: def test_noise_mask_true_attaches_latent_mask() -> None: - """Noise-mask mode attaches a latent mask before tiled sampling.""" + """Noise-mask mode attaches the crop-local mask before tiled sampling.""" sampler = _FakeTiledSampler() service = _service(sampler) @@ -163,6 +163,54 @@ def test_noise_mask_true_attaches_latent_mask() -> None: assert noise_mask.shape == (1, 4, 4) +def test_tiled_paste_mask_uses_gaussian_feathered_crop_mask() -> None: + """Final tiled compositing uses a softened crop-local detailer mask.""" + + sampler = _FakeTiledSampler() + service = _service(sampler) + mask = torch.zeros((4, 4), dtype=torch.float32) + mask[1:3, 1:3] = 1.0 + + result = service.detail( + _image(), + _segs(_segment(cropped_mask=mask)), + "model", + "vae", + [], + [], + **(_settings() | {"scale_factor": 1.0, "feather": 1}), + ) + + crop = result.image[:, 2:6, 2:6, 0] + assert 0.0 < float(crop[0, 0, 1]) < 1.0 + assert float(crop[0, 1, 1]) > float(crop[0, 0, 1]) + assert float(crop[0, 3, 3]) < float(crop[0, 1, 1]) + + +def test_tiled_noise_mask_feather_keeps_sampled_crop_geometry() -> None: + """Tiled denoise masks keep original crop geometry for ComfyUI resizing.""" + + sampler = _FakeTiledSampler() + service = _service(sampler) + mask = torch.zeros((4, 4), dtype=torch.float32) + mask[1:3, 1:3] = 1.0 + + service.detail( + _image(), + _segs(_segment(cropped_mask=mask)), + "model", + "vae", + [], + [], + **(_settings() | {"noise_mask_feather": 1}), + ) + + noise_mask = sampler.sample_calls[0]["latent_image"]["noise_mask"] + assert noise_mask.shape == (1, 4, 4) + assert 0.0 < float(noise_mask[0, 0, 1]) < 1.0 + assert float(noise_mask[0, 1, 1]) > float(noise_mask[0, 0, 1]) + + def test_tiled_detail_sampler_delegates_to_shared_sampling_service() -> None: """The detailer adapter uses the shared tiled diffusion dispatch service.""" @@ -398,12 +446,12 @@ def _segs(*segments: Segment) -> NativeSegs: return (8, 8), tuple(segments) -def _segment() -> Segment: +def _segment(cropped_mask: object | None = None) -> Segment: """Return one test segment with a cropped mask.""" return Segment( cropped_image=None, - cropped_mask=torch.ones((4, 4)), + cropped_mask=cropped_mask if cropped_mask is not None else torch.ones((4, 4)), confidence=1.0, crop_region=CropRegion(2, 2, 6, 6), bbox=BoundingBox(3, 3, 5, 5), diff --git a/tests/test_detect_segs_with_ultralytics_node.py b/tests/test_detect_segs_with_ultralytics_node.py index bfc02c2..882198f 100644 --- a/tests/test_detect_segs_with_ultralytics_node.py +++ b/tests/test_detect_segs_with_ultralytics_node.py @@ -379,14 +379,18 @@ class _FakeCombinedBuilder: self.call_count = 0 self.seen_labels: list[list[str]] = [] + self.seen_crop_factors: list[float] = [] - def __call__(self, image: object, segs: NativeSegs) -> CombinedSegsResult: + def __call__( + self, image: object, segs: NativeSegs, crop_factor: float + ) -> CombinedSegsResult: """Return deterministic combined outputs and record the call.""" del image self.call_count += 1 header, segments = segs self.seen_labels.append([segment.label for segment in segments]) + self.seen_crop_factors.append(crop_factor) return CombinedSegsResult( segs=(header, (_segment("combined", CropRegion(0, 0, 4, 4)),)), mask=torch.ones((1, header[0], header[1]), dtype=torch.float32), @@ -401,10 +405,12 @@ class _FakeBatchCombinedBuilder: self.call_count = 0 - def __call__(self, image: object, segs: NativeSegs) -> CombinedSegsResult: + def __call__( + self, image: object, segs: NativeSegs, crop_factor: float + ) -> CombinedSegsResult: """Return a combined segment tied to the source image label.""" - del image + del image, crop_factor self.call_count += 1 header, segments = segs label = f"combined-{segments[0].label}" if segments else "combined" diff --git a/tests/test_prompt_segs_with_sam_node.py b/tests/test_prompt_segs_with_sam_node.py index 05363e5..2c3aa39 100644 --- a/tests/test_prompt_segs_with_sam_node.py +++ b/tests/test_prompt_segs_with_sam_node.py @@ -256,12 +256,14 @@ class _FixedCombinedBuilder: self, source_image: object, source_segs: tuple[tuple[int, int], tuple[Segment, ...]], + crop_factor: float, ) -> CombinedSegsResult: """Return the fixed combined result after checking call arguments.""" self.call_count += 1 assert torch.equal(cast(torch.Tensor, source_image), self._expected_image) assert source_segs is self._expected_segs + assert crop_factor == 3.0 return self._combined @@ -277,11 +279,13 @@ class _FakeBatchCombinedBuilder: self, source_image: object, source_segs: tuple[tuple[int, int], tuple[Segment, ...]], + crop_factor: float, ) -> CombinedSegsResult: """Return a combined result for one image slice.""" self.call_count += 1 assert cast(torch.Tensor, source_image).shape == (1, 3, 3, 3) + assert crop_factor == 3.0 segment_label = source_segs[1][0].label return CombinedSegsResult( segs=_segs( diff --git a/tests/test_prompt_segs_with_sam_service.py b/tests/test_prompt_segs_with_sam_service.py index 4b0f8cc..38e285c 100644 --- a/tests/test_prompt_segs_with_sam_service.py +++ b/tests/test_prompt_segs_with_sam_service.py @@ -255,7 +255,7 @@ def test_service_applies_mask_dilation() -> None: service, positive_prompt="face", size_threshold=1, - mask_dilation=1, + mask_dilation=3, crop_factor=1.0, ) diff --git a/tests/test_ultralytics_detection_service.py b/tests/test_ultralytics_detection_service.py index bf4085c..7680b3f 100644 --- a/tests/test_ultralytics_detection_service.py +++ b/tests/test_ultralytics_detection_service.py @@ -13,7 +13,10 @@ import pytest import torch from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment -from simple_syrup.runtime.ultralytics_detection import UltralyticsDetection +from simple_syrup.runtime.ultralytics_detection import ( + UltralyticsDetection, + parse_ultralytics_result, +) from simple_syrup.runtime.ultralytics_loader import UltralyticsDetectorModel from simple_syrup.services.segs_detection_service import ( DetectionRunner, @@ -33,6 +36,7 @@ def test_bbox_prediction_creates_rectangular_mask_segs() -> None: assert len(segments) == 1 assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 9.0 assert segments[0].crop_region == (2, 2, 5, 5) + assert torch.all(cast(torch.Tensor, segments[0].cropped_mask) == 1.0) def test_segmentation_prediction_uses_mask_shape() -> None: @@ -70,7 +74,7 @@ def test_drop_size_filters_tiny_detections() -> None: assert segments == () -def test_crop_factor_expands_and_clamps_region() -> None: +def test_crop_factor_expands_region() -> None: """Crop factor expands around the bbox without leaving image bounds.""" service = SegsDetectionService(detection_runner=_runner(_bbox_detection())) @@ -78,6 +82,21 @@ def test_crop_factor_expands_and_clamps_region() -> None: _header, segments = service.detect(_image(), _model(False), 0.5, 0, 3.0, 1) assert segments[0].crop_region == (0, 0, 8, 8) + cropped_mask = cast(torch.Tensor, segments[0].cropped_mask) + assert cropped_mask.sum().item() == 9.0 + assert torch.count_nonzero(cropped_mask == 0.0).item() > 0 + + +def test_crop_factor_shifts_region_at_image_edges() -> None: + """Edge crops preserve requested context by shifting back into the image.""" + + detection = UltralyticsDetection(BoundingBox(0, 2, 2, 4), 0.9, "face", None) + service = SegsDetectionService(detection_runner=_runner(detection)) + + _header, segments = service.detect(_image(), _model(False), 0.5, 0, 3.0, 1) + + assert segments[0].crop_region == CropRegion(0, 0, 6, 6) + assert cast(torch.Tensor, segments[0].cropped_mask).shape == (6, 6) def test_crop_factor_zero_uses_full_image_region() -> None: @@ -101,23 +120,45 @@ def test_fractional_crop_factor_below_one_still_fails() -> None: service.detect(_image(), _model(False), 0.5, 0, 0.5, 1) -def test_dilation_changes_mask_extent() -> None: - """Dilation expands rectangular mask coverage.""" +def test_dilation_factor_uses_kernel_size() -> None: + """Positive dilation uses the factor as the morphology kernel size.""" - service = SegsDetectionService(detection_runner=_runner(_bbox_detection())) + detection = UltralyticsDetection(BoundingBox(2, 2, 4, 4), 0.9, "face", None) + service = SegsDetectionService(detection_runner=_runner(detection)) - _header, segments = service.detect(_image(), _model(False), 0.5, 1, 1.0, 1) + _header, segments = service.detect(_image(), _model(False), 0.5, 2, 3.0, 1) - assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 9.0 + full_mask = torch.zeros((8, 8), dtype=torch.float32) + region = segments[0].crop_region + full_mask[region.top : region.bottom, region.left : region.right] = cast( + torch.Tensor, segments[0].cropped_mask + ) + + assert torch.equal( + full_mask, + torch.tensor( + [ + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 1, 1, 0, 0, 0], + [0, 0, 1, 1, 1, 0, 0, 0], + [0, 0, 1, 1, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + ], + dtype=torch.float32, + ), + ) -def test_negative_dilation_erodes_mask_extent() -> None: - """Negative dilation erodes the detected mask.""" +def test_negative_dilation_factor_uses_kernel_size() -> None: + """Negative dilation erodes with the factor as the morphology kernel size.""" detection = UltralyticsDetection(BoundingBox(1, 1, 7, 7), 0.9, "face", None) service = SegsDetectionService(detection_runner=_runner(detection)) - _header, segments = service.detect(_image(), _model(False), 0.5, -1, 1.0, 1) + _header, segments = service.detect(_image(), _model(False), 0.5, -3, 1.0, 1) assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 16.0 @@ -175,12 +216,39 @@ def test_post_dilation_changes_cropped_mask_after_crop() -> None: 0, 3.0, 1, - post_dilation=-1, + post_dilation=-3, ) assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 1.0 +def test_ultralytics_segmentation_masks_drop_aspect_padding_before_resize() -> None: + """Segmentation masks remove model-shape padding before image normalization.""" + + mask = torch.zeros((1, 6, 5), dtype=torch.float32) + mask[:, 1:5, :] = 1.0 + result = _UltralyticsResult( + boxes=_Boxes( + xyxy=torch.tensor([[0.0, 0.0, 4.0, 4.0]]), + conf=torch.tensor([0.9]), + cls=torch.tensor([0.0]), + ), + masks=_Masks(mask), + ) + + detections = parse_ultralytics_result( + result, + image_height=4, + image_width=4, + class_names={0: "face"}, + prefer_segmentation=True, + ) + + assert len(detections) == 1 + assert detections[0].mask is not None + assert torch.all(detections[0].mask == 1.0) + + def test_simple_detector_refines_bbox_segs_with_segmentation_mask() -> None: """Simple detection intersects bbox SEGS with segmentation sub-detections.""" @@ -248,7 +316,7 @@ def test_empty_detections_return_empty_segs() -> None: def test_empty_segs_build_empty_combined_outputs() -> None: """Empty SEGS produce empty combined SEGS and a zero mask.""" - result = build_combined_segs_result(_image(), ((8, 8), ())) + result = build_combined_segs_result(_image(), ((8, 8), ()), crop_factor=1.0) assert result.segs == ((8, 8), ()) assert result.mask.shape == (1, 8, 8) @@ -257,7 +325,7 @@ def test_empty_segs_build_empty_combined_outputs() -> None: def test_combined_result_unions_all_segment_masks() -> None: - """Combined SEGS contain one tight segment for all source masks.""" + """Combined SEGS contain one unioned segment for all source masks.""" image = torch.arange(1 * 8 * 8 * 3, dtype=torch.float32).reshape((1, 8, 8, 3)) image = image / image.max() @@ -268,7 +336,7 @@ def test_combined_result_unions_all_segment_masks() -> None: [[2.0, 2.0, 2.0], [2.0, 2.0, 2.0]], ) - result = build_combined_segs_result(image, _segs(first, second)) + result = build_combined_segs_result(image, _segs(first, second), crop_factor=1.0) _header, segments = result.segs assert result.mask.shape == (1, 8, 8) @@ -284,13 +352,49 @@ def test_combined_result_unions_all_segment_masks() -> None: ) +def test_combined_result_applies_crop_factor_after_combining() -> None: + """Combined SEGS expand one unioned target for downstream detailers.""" + + image = torch.arange(1 * 8 * 8 * 3, dtype=torch.float32).reshape((1, 8, 8, 3)) + image = image / image.max() + first_mask = torch.zeros((2, 2), dtype=torch.float32) + first_mask[0, 0] = 1.0 + second_mask = torch.zeros((2, 2), dtype=torch.float32) + second_mask[1, 1] = 1.0 + first = Segment( + cropped_image=None, + cropped_mask=first_mask, + confidence=0.8, + crop_region=CropRegion(2, 2, 4, 4), + bbox=BoundingBox(2, 2, 3, 3), + label="face", + ) + second = Segment( + cropped_image=None, + cropped_mask=second_mask, + confidence=0.9, + crop_region=CropRegion(4, 4, 6, 6), + bbox=BoundingBox(5, 5, 6, 6), + label="face", + ) + + result = build_combined_segs_result(image, _segs(first, second), crop_factor=2.0) + _header, segments = result.segs + + assert len(segments) == 1 + assert segments[0].crop_region == CropRegion(0, 0, 8, 8) + assert segments[0].bbox == BoundingBox(2, 2, 6, 6) + assert cast(torch.Tensor, segments[0].cropped_mask).shape == (8, 8) + assert torch.equal(cast(torch.Tensor, segments[0].cropped_image), image) + + def test_combined_mask_uses_max_for_overlaps() -> None: """Overlapping source masks combine by max instead of addition.""" first = _segment(CropRegion(2, 2, 4, 4), 0.4, torch.full((2, 2), 0.7)) second = _segment(CropRegion(2, 2, 4, 4), 0.8, torch.full((2, 2), 0.8)) - result = build_combined_segs_result(_image(), _segs(first, second)) + result = build_combined_segs_result(_image(), _segs(first, second), crop_factor=1.0) assert result.mask[0, 2, 2].item() == pytest.approx(0.8) assert result.mask.sum().item() == pytest.approx(3.2) @@ -302,7 +406,7 @@ def test_combined_outputs_reject_mismatched_cropped_mask_shape() -> None: segment = _segment(CropRegion(1, 1, 3, 3), 1.0, torch.ones((3, 3))) with pytest.raises(ValueError, match="cropped_mask must match"): - build_combined_segs_result(_image(), _segs(segment)) + build_combined_segs_result(_image(), _segs(segment), crop_factor=1.0) def test_batch_image_input_fails_clearly() -> None: @@ -388,6 +492,42 @@ class _SequentialRunner: return self._responses.pop(0) +class _Boxes: + """Fake Ultralytics boxes object for parser tests.""" + + def __init__( + self, + xyxy: torch.Tensor, + conf: torch.Tensor, + cls: torch.Tensor, + ) -> None: + """Store tensor-like Ultralytics box fields.""" + + self.xyxy = xyxy + self.conf = conf + self.cls = cls + + +class _Masks: + """Fake Ultralytics masks object for parser tests.""" + + def __init__(self, data: torch.Tensor) -> None: + """Store tensor-like Ultralytics mask data.""" + + self.data = data + + +class _UltralyticsResult: + """Fake Ultralytics result object for parser tests.""" + + def __init__(self, boxes: _Boxes, masks: _Masks) -> None: + """Store result-like fields.""" + + self.boxes = boxes + self.masks = masks + self.names = {0: "face"} + + def _image() -> torch.Tensor: """Return a small single-image tensor.""" From fcaa2c4e3004157ad13bff557189f10b40b808da Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Fri, 22 May 2026 22:13:18 -0400 Subject: [PATCH 04/40] feat(detection): add keep-only SEGS selection --- simple_syrup/domain/segs.py | 37 +++ .../masking/prompt_segs_with_sam_service.py | 10 +- .../nodes/detect_segs_with_ultralytics.py | 28 +++ simple_syrup/nodes/prompt_segs_with_sam.py | 31 ++- .../test_detect_segs_with_ultralytics_node.py | 231 ++++++++++++++++++ ...test_prompt_segs_with_sam_compatibility.py | 2 - tests/test_prompt_segs_with_sam_node.py | 171 ++++++++++++- tests/test_prompt_segs_with_sam_service.py | 9 +- tests/test_segs_domain.py | 108 ++++++++ 9 files changed, 608 insertions(+), 19 deletions(-) diff --git a/simple_syrup/domain/segs.py b/simple_syrup/domain/segs.py index 66e943f..96daa6c 100644 --- a/simple_syrup/domain/segs.py +++ b/simple_syrup/domain/segs.py @@ -98,6 +98,10 @@ SORT_ORDER_OPTIONS: tuple[str, ...] = ( "highest confidence first", "lowest confidence first", ) +KEEP_BY_OPTIONS: tuple[str, ...] = ( + "highest confidence", + "largest size", +) def coerce_segs(value: object) -> NativeSegs: @@ -180,6 +184,28 @@ def to_impact_compatible_segs_group(segs_group: NativeSegsGroup) -> list[ImpactS return [to_impact_compatible_segs(segs) for segs in segs_group] +def limit_segs(segs: NativeSegs, keep_only: int, keep_by: str) -> NativeSegs: + """Return SEGS limited by a user-facing ranking policy.""" + + header, segments = coerce_segs(segs) + if keep_only < 0: + raise ValueError("keep_only must be greater than or equal to 0.") + if keep_by not in KEEP_BY_OPTIONS: + raise ValueError(f"Unknown SEGS keep_by option: '{keep_by}'.") + if keep_only == 0 or len(segments) <= keep_only: + return header, segments + + indexed_segments = tuple(enumerate(segments)) + ranked = sorted( + indexed_segments, + key=lambda item: _keep_key(item[1], item[0], keep_by), + ) + selected_indexes = {index for index, _segment in ranked[:keep_only]} + return header, tuple( + segment for index, segment in indexed_segments if index in selected_indexes + ) + + def sort_segs(segs: NativeSegs, sort_order: str) -> NativeSegs: """Return native SEGS sorted by a plain-English policy.""" @@ -235,6 +261,17 @@ def _sort_key(segment: Segment, index: int, sort_order: str) -> SortKey: return primary, confidence_desc, region.top, region.left, index +def _keep_key(segment: Segment, index: int, keep_by: str) -> tuple[float, int]: + """Build a deterministic selection key for one segment.""" + + if keep_by == "highest confidence": + return -float(segment.confidence), index + if keep_by == "largest size": + region = segment.crop_region + return -float(region.width * region.height), index + raise ValueError(f"Unknown SEGS keep_by option: '{keep_by}'.") + + def _coerce_header(value: object) -> SegsHeader: """Convert a SEGS header to an image shape tuple.""" diff --git a/simple_syrup/masking/prompt_segs_with_sam_service.py b/simple_syrup/masking/prompt_segs_with_sam_service.py index 4914f3e..c03a9a6 100644 --- a/simple_syrup/masking/prompt_segs_with_sam_service.py +++ b/simple_syrup/masking/prompt_segs_with_sam_service.py @@ -14,7 +14,6 @@ from ..domain.segs import ( BoundingBox, NativeSegs, Segment, - sort_segs, ) from ..masking.mask_ops import MaskRefinementSettings, refine_prompt_mask from ..masking.segs_mask_ops import ( @@ -53,7 +52,6 @@ class PromptSegsSettings: bbox_dilation: int mask_dilation: int crop_factor: float - sort_order: str refinement: MaskRefinementSettings @@ -94,9 +92,8 @@ class PromptSEGSWithSAMService: mask_refinement_max_size: int, execution_device: str, crop_factor: float, - sort_order: str, ) -> NativeSegs: - """Return sorted native SEGS for a text-prompted SAM detection.""" + """Return native SEGS for a text-prompted SAM detection.""" image_tensor = validate_single_image(image, "Prompt SEGS w/ SAM") settings = self._validate_settings( @@ -115,7 +112,6 @@ class PromptSEGSWithSAMService: mask_refinement_max_size=mask_refinement_max_size, execution_device=execution_device, crop_factor=crop_factor, - sort_order=sort_order, ) sample = image_tensor[0] @@ -176,7 +172,7 @@ class PromptSEGSWithSAMService: ) ) - return sort_segs(((height, width), tuple(segments)), settings.sort_order) + return (height, width), tuple(segments) def _prompt_regions( self, @@ -307,7 +303,6 @@ class PromptSEGSWithSAMService: mask_refinement_max_size: int, execution_device: str, crop_factor: float, - sort_order: str, ) -> PromptSegsSettings: """Validate public node settings and return normalized values.""" @@ -356,7 +351,6 @@ class PromptSEGSWithSAMService: bbox_dilation=int(bbox_dilation), mask_dilation=int(mask_dilation), crop_factor=float(crop_factor), - sort_order=sort_order, refinement=refinement, ) diff --git a/simple_syrup/nodes/detect_segs_with_ultralytics.py b/simple_syrup/nodes/detect_segs_with_ultralytics.py index 6ffdb2c..065f3ce 100644 --- a/simple_syrup/nodes/detect_segs_with_ultralytics.py +++ b/simple_syrup/nodes/detect_segs_with_ultralytics.py @@ -12,8 +12,10 @@ from typing import Any, ClassVar import torch from ..domain.segs import ( + KEEP_BY_OPTIONS, SORT_ORDER_OPTIONS, NativeSegs, + limit_segs, sort_segs, to_impact_compatible_segs, ) @@ -94,6 +96,29 @@ class DetectSEGSWithUltralytics: ), }, ), + "keep_only": ( + "INT", + { + "default": 0, + "min": 0, + "max": 4096, + "step": 1, + "tooltip": ( + "Keep only this many detected regions after threshold " + "filtering. Use 0 to keep all regions." + ), + }, + ), + "keep_by": ( + KEEP_BY_OPTIONS, + { + "default": "highest confidence", + "tooltip": ( + "Choose how regions are ranked when Keep Only is " + "greater than 0." + ), + }, + ), "bbox_dilation": ( "INT", { @@ -178,6 +203,8 @@ class DetectSEGSWithUltralytics: detector_model: UltralyticsDetectorModel, confidence_threshold: float, size_threshold: int, + keep_only: int, + keep_by: str, bbox_dilation: int, sub_dilation: int, post_dilation: int, @@ -204,6 +231,7 @@ 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 diff --git a/simple_syrup/nodes/prompt_segs_with_sam.py b/simple_syrup/nodes/prompt_segs_with_sam.py index 79d4227..0865ef1 100644 --- a/simple_syrup/nodes/prompt_segs_with_sam.py +++ b/simple_syrup/nodes/prompt_segs_with_sam.py @@ -12,8 +12,11 @@ from typing import Any, ClassVar import torch from ..domain.segs import ( + KEEP_BY_OPTIONS, SORT_ORDER_OPTIONS, NativeSegs, + limit_segs, + sort_segs, to_impact_compatible_segs, ) from ..masking.mask_ops import DETAIL_METHODS @@ -120,6 +123,29 @@ class PromptSEGSWithSAM: ), }, ), + "keep_only": ( + "INT", + { + "default": 0, + "min": 0, + "max": 4096, + "step": 1, + "tooltip": ( + "Keep only this many detected regions after threshold " + "filtering. Use 0 to keep all regions." + ), + }, + ), + "keep_by": ( + KEEP_BY_OPTIONS, + { + "default": "highest confidence", + "tooltip": ( + "Choose how regions are ranked when Keep Only is " + "greater than 0." + ), + }, + ), "bbox_dilation": ( "INT", { @@ -289,6 +315,8 @@ class PromptSEGSWithSAM: negative_prompt: str, confidence_threshold: float, size_threshold: int, + keep_only: int, + keep_by: str, bbox_dilation: int, mask_dilation: int, detail_method: str, @@ -331,8 +359,9 @@ class PromptSEGSWithSAM: mask_refinement_max_size=mask_refinement_max_size, execution_device=execution_device, crop_factor=crop_factor, - sort_order=sort_order, ) + 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)) diff --git a/tests/test_detect_segs_with_ultralytics_node.py b/tests/test_detect_segs_with_ultralytics_node.py index 882198f..f7b2ea1 100644 --- a/tests/test_detect_segs_with_ultralytics_node.py +++ b/tests/test_detect_segs_with_ultralytics_node.py @@ -54,6 +54,8 @@ def test_detect_segs_with_ultralytics_node_contract( "detector_model", "confidence_threshold", "size_threshold", + "keep_only", + "keep_by", "bbox_dilation", "sub_dilation", "post_dilation", @@ -63,6 +65,21 @@ def test_detect_segs_with_ultralytics_node_contract( ] assert inputs["required"]["bbox_dilation"][1]["min"] == -512 assert inputs["required"]["size_threshold"][1]["default"] == 10 + assert inputs["required"]["keep_only"][1]["default"] == 0 + assert inputs["required"]["keep_only"][1]["min"] == 0 + assert inputs["required"]["keep_only"][1]["max"] == 4096 + assert inputs["required"]["keep_only"][1]["tooltip"] == ( + "Keep only this many detected regions after threshold filtering. Use 0 " + "to keep all regions." + ) + assert inputs["required"]["keep_by"][0] == ( + "highest confidence", + "largest size", + ) + assert inputs["required"]["keep_by"][1]["default"] == "highest confidence" + assert inputs["required"]["keep_by"][1]["tooltip"] == ( + "Choose how regions are ranked when Keep Only is greater than 0." + ) assert inputs["required"]["post_dilation"][1]["default"] == 0 assert inputs["required"]["crop_factor"][1]["min"] == 0.0 assert inputs["required"]["sort_order"][0] == SORT_ORDER_OPTIONS @@ -142,6 +159,8 @@ def test_detect_segs_with_ultralytics_node_uses_story_input_order( _model(), 0.7, 12, + 0, + "highest confidence", -2, 3, -1, @@ -170,6 +189,8 @@ def test_detector_node_sorts_segs_before_outputs( 0.7, 1, 0, + "highest confidence", + 0, 0, 0, 1.0, @@ -183,6 +204,108 @@ def test_detector_node_sorts_segs_before_outputs( assert builder.seen_labels == [["large", "small"]] +def test_detector_node_keeps_only_highest_confidence_seg( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep Only can retain just the strongest detection.""" + + builder = _FakeCombinedBuilder() + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeRankingSegsService, + ) + monkeypatch.setattr(DetectSEGSWithUltralytics, "combined_builder", builder) + + segs, _mask = DetectSEGSWithUltralytics().detect( + torch.zeros((1, 8, 8, 3)), + _model(), + 0.7, + 1, + 1, + "highest confidence", + 0, + 0, + 0, + 1.0, + "largest to smallest", + False, + ) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + _header, segments = segs_list[0] + assert [segment.label for segment in segments] == ["small-high"] + assert builder.seen_labels == [["small-high"]] + + +def test_detector_node_keeps_largest_segs_then_sorts_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Size limiting happens before final sort_order output ordering.""" + + builder = _FakeCombinedBuilder() + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeRankingSegsService, + ) + monkeypatch.setattr(DetectSEGSWithUltralytics, "combined_builder", builder) + + segs, _mask = DetectSEGSWithUltralytics().detect( + torch.zeros((1, 8, 8, 3)), + _model(), + 0.7, + 1, + 2, + "largest size", + 0, + 0, + 0, + 1.0, + "left to right", + False, + ) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + _header, segments = segs_list[0] + assert [segment.label for segment in segments] == ["medium", "large-low"] + assert builder.seen_labels == [["medium", "large-low"]] + + +def test_detector_node_combines_only_limited_segs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unioned outputs are built from the selected SEGS only.""" + + builder = _FakeCombinedBuilder() + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeRankingSegsService, + ) + monkeypatch.setattr(DetectSEGSWithUltralytics, "combined_builder", builder) + + segs, _mask = DetectSEGSWithUltralytics().detect( + torch.zeros((1, 8, 8, 3)), + _model(), + 0.7, + 1, + 1, + "highest confidence", + 0, + 0, + 0, + 1.0, + "largest to smallest", + True, + ) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + _header, segments = segs_list[0] + assert [segment.label for segment in segments] == ["combined"] + assert builder.seen_labels == [["small-high"]] + + def test_detector_node_processes_image_batches_with_individual_segs( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -204,6 +327,8 @@ def test_detector_node_processes_image_batches_with_individual_segs( 0.5, 1, 0, + "highest confidence", + 0, 0, 0, 1.0, @@ -222,6 +347,43 @@ def test_detector_node_processes_image_batches_with_individual_segs( assert cast(torch.Tensor, mask).shape == (2, 8, 8) +def test_detector_node_limits_each_batch_image_independently( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep Only is applied separately to each one-image SEGS payload.""" + + builder = _FakeBatchCombinedBuilder() + monkeypatch.setattr( + DetectSEGSWithUltralytics, + "service_class", + _FakeBatchRankingSegsService, + ) + monkeypatch.setattr(DetectSEGSWithUltralytics, "combined_builder", builder) + + segs, mask = DetectSEGSWithUltralytics().detect( + torch.zeros((2, 8, 8, 3), dtype=torch.float32), + _model(), + 0.5, + 1, + 1, + "highest confidence", + 0, + 0, + 0, + 1.0, + "largest to smallest", + False, + ) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + assert [segments[0].label for _header, segments in segs_list] == [ + "image-0-high", + "image-1-high", + ] + assert builder.call_count == 2 + assert cast(torch.Tensor, mask).shape == (2, 8, 8) + + def test_detector_node_processes_image_batches_with_unioned_segs( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -241,6 +403,8 @@ def test_detector_node_processes_image_batches_with_unioned_segs( 0.5, 1, 0, + "highest confidence", + 0, 0, 0, 1.0, @@ -313,6 +477,35 @@ class _FakeUnsortedSegsService: ) +class _FakeRankingSegsService: + """Fake SEGS service that returns rankable segments.""" + + def detect_simple( + self, + image: object, + detector_model: UltralyticsDetectorModel, + bbox_threshold: float, + bbox_dilation: int, + crop_factor: float, + drop_size: int, + sub_threshold: float, + sub_dilation: int, + post_dilation: int = 0, + ) -> NativeSegs: + """Return segments with different confidence and size values.""" + + del image, detector_model, bbox_threshold, bbox_dilation, crop_factor + del drop_size, sub_threshold, sub_dilation, post_dilation + return ( + (8, 8), + ( + _segment("small-high", CropRegion(0, 0, 2, 2), confidence=0.95), + _segment("large-low", CropRegion(4, 0, 8, 4), confidence=0.1), + _segment("medium", CropRegion(2, 0, 5, 3), confidence=0.5), + ), + ) + + class _FakeBatchSegsService: """Fake SEGS service that returns one labeled segment per image call.""" @@ -343,6 +536,42 @@ class _FakeBatchSegsService: return (8, 8), (_segment(label, CropRegion(0, 0, 2, 2)),) +class _FakeBatchRankingSegsService: + """Fake SEGS service that returns rankable segments per image.""" + + def __init__(self) -> None: + """Create an empty image-call counter.""" + + self._call_count = 0 + + def detect_simple( + self, + image: object, + detector_model: UltralyticsDetectorModel, + bbox_threshold: float, + bbox_dilation: int, + crop_factor: float, + drop_size: int, + sub_threshold: float, + sub_dilation: int, + post_dilation: int = 0, + ) -> NativeSegs: + """Return high and low confidence detections for one image slice.""" + + del detector_model, bbox_threshold, bbox_dilation, crop_factor + del drop_size, sub_threshold, sub_dilation, post_dilation + assert cast(torch.Tensor, image).shape == (1, 8, 8, 3) + label_prefix = f"image-{self._call_count}" + self._call_count += 1 + return ( + (8, 8), + ( + _segment(f"{label_prefix}-low", CropRegion(0, 0, 4, 4), 0.1), + _segment(f"{label_prefix}-high", CropRegion(4, 4, 6, 6), 0.9), + ), + ) + + class _FakeStoryOrderSegsService: """Fake SEGS service that verifies public detector input mapping.""" @@ -429,6 +658,8 @@ def _detect(combine_segs: bool) -> tuple[object, object]: 0.5, 1, 0, + "highest confidence", + 0, 2, -1, 1.0, diff --git a/tests/test_prompt_segs_with_sam_compatibility.py b/tests/test_prompt_segs_with_sam_compatibility.py index 06c4bcb..6d2617e 100644 --- a/tests/test_prompt_segs_with_sam_compatibility.py +++ b/tests/test_prompt_segs_with_sam_compatibility.py @@ -71,7 +71,6 @@ def test_prompt_service_accepts_impact_style_sam_model() -> None: mask_refinement_max_size=4, execution_device="cpu", crop_factor=1.0, - sort_order="largest to smallest", ) assert len(result[1]) == 1 @@ -106,5 +105,4 @@ def test_prompt_service_rejects_invalid_dino_model() -> None: mask_refinement_max_size=4, execution_device="cpu", crop_factor=1.0, - sort_order="largest to smallest", ) diff --git a/tests/test_prompt_segs_with_sam_node.py b/tests/test_prompt_segs_with_sam_node.py index 2c3aa39..8d3847d 100644 --- a/tests/test_prompt_segs_with_sam_node.py +++ b/tests/test_prompt_segs_with_sam_node.py @@ -54,6 +54,8 @@ def test_prompt_segs_with_sam_node_declares_expected_inputs() -> None: "negative_prompt", "confidence_threshold", "size_threshold", + "keep_only", + "keep_by", "bbox_dilation", "mask_dilation", "detail_method", @@ -80,6 +82,18 @@ def test_prompt_segs_with_sam_node_declares_expected_inputs() -> None: assert required["positive_prompt"][1]["tooltip"] == ( "Text describing the regions to detect." ) + assert required["keep_only"][1]["default"] == 0 + assert required["keep_only"][1]["min"] == 0 + assert required["keep_only"][1]["max"] == 4096 + assert required["keep_only"][1]["tooltip"] == ( + "Keep only this many detected regions after threshold filtering. Use 0 " + "to keep all regions." + ) + assert required["keep_by"][0] == ("highest confidence", "largest size") + assert required["keep_by"][1]["default"] == "highest confidence" + assert required["keep_by"][1]["tooltip"] == ( + "Choose how regions are ranked when Keep Only is greater than 0." + ) assert required["detail_method"][0] == ["GuidedFilter", "PyMatting", "VITMatte"] assert required["sort_order"][0] == SORT_ORDER_OPTIONS assert required["sort_order"][1]["default"] == SORT_ORDER_OPTIONS[0] @@ -155,6 +169,91 @@ def test_prompt_segs_with_sam_node_returns_unioned_segs_when_combine_enabled() - assert torch.equal(cast(torch.Tensor, result[1]), combined.mask) +def test_prompt_segs_with_sam_node_keeps_only_highest_confidence_seg() -> None: + """Keep Only can retain just the strongest prompted region.""" + + image = make_image_tensor(batch_size=1, height=8, width=8) + segs = _ranking_segs() + expected_limited = _segs(_segment("small-high", CropRegion(0, 0, 2, 2), 0.95)) + combined = CombinedSegsResult( + segs=_segs(_segment("combined", CropRegion(0, 0, 8, 8), 1.0)), + mask=torch.ones((1, 8, 8), dtype=torch.float32), + ) + builder = _FixedCombinedBuilder(image, expected_limited, combined) + + result = _prompt_with_fakes( + service=_FakePromptService(segs), + builder=builder, + image=image, + combine_segs=False, + keep_only=1, + keep_by="highest confidence", + ) + + segs_list = cast(list[tuple[object, list[Segment]]], result[0]) + _header, segments = segs_list[0] + assert [segment.label for segment in segments] == ["small-high"] + assert builder.seen_labels == [["small-high"]] + + +def test_prompt_segs_with_sam_node_keeps_largest_segs_then_sorts_output() -> None: + """Size limiting happens before final sort_order output ordering.""" + + image = make_image_tensor(batch_size=1, height=8, width=8) + segs = _ranking_segs() + expected_limited = _segs( + _segment("medium", CropRegion(2, 0, 5, 3), 0.5), + _segment("large-low", CropRegion(4, 0, 8, 4), 0.1), + ) + combined = CombinedSegsResult( + segs=_segs(_segment("combined", CropRegion(0, 0, 8, 8), 1.0)), + mask=torch.ones((1, 8, 8), dtype=torch.float32), + ) + builder = _FixedCombinedBuilder(image, expected_limited, combined) + + result = _prompt_with_fakes( + service=_FakePromptService(segs), + builder=builder, + image=image, + combine_segs=False, + keep_only=2, + keep_by="largest size", + sort_order="left to right", + ) + + segs_list = cast(list[tuple[object, list[Segment]]], result[0]) + _header, segments = segs_list[0] + assert [segment.label for segment in segments] == ["medium", "large-low"] + assert builder.seen_labels == [["medium", "large-low"]] + + +def test_prompt_segs_with_sam_node_combines_only_limited_segs() -> None: + """Unioned prompted outputs are built from the selected SEGS only.""" + + image = make_image_tensor(batch_size=1, height=8, width=8) + segs = _ranking_segs() + expected_limited = _segs(_segment("small-high", CropRegion(0, 0, 2, 2), 0.95)) + combined = CombinedSegsResult( + segs=_segs(_segment("combined", CropRegion(0, 0, 8, 8), 1.0)), + mask=torch.ones((1, 8, 8), dtype=torch.float32), + ) + builder = _FixedCombinedBuilder(image, expected_limited, combined) + + result = _prompt_with_fakes( + service=_FakePromptService(segs), + builder=builder, + image=image, + combine_segs=True, + keep_only=1, + keep_by="highest confidence", + ) + + segs_list = cast(list[tuple[object, list[Segment]]], result[0]) + _header, segments = segs_list[0] + assert [segment.label for segment in segments] == ["combined"] + assert builder.seen_labels == [["small-high"]] + + def test_prompt_segs_with_sam_node_processes_image_batches_with_individual_segs() -> ( None ): @@ -181,6 +280,31 @@ def test_prompt_segs_with_sam_node_processes_image_batches_with_individual_segs( assert cast(torch.Tensor, result[1]).shape == (2, 3, 3) +def test_prompt_segs_with_sam_node_limits_each_batch_image_independently() -> None: + """Keep Only is applied separately to each prompted one-image SEGS payload.""" + + image = make_image_tensor(batch_size=2, height=3, width=3) + service = _FakeBatchRankingPromptService() + builder = _FakeBatchCombinedBuilder() + + result = _prompt_with_fakes( + service=service, + builder=builder, + image=image, + combine_segs=False, + keep_only=1, + keep_by="highest confidence", + ) + + segs_list = cast(list[tuple[object, list[Segment]]], result[0]) + assert [segments[0].label for _header, segments in segs_list] == [ + "image-0-high", + "image-1-high", + ] + assert builder.call_count == 2 + assert cast(torch.Tensor, result[1]).shape == (2, 3, 3) + + def test_prompt_segs_with_sam_node_processes_image_batches_with_unioned_segs() -> None: """A batch image produces one combined SEGS output per image.""" @@ -236,6 +360,26 @@ class _FakeBatchPromptService: return _segs(_segment(label, CropRegion(0, 0, 2, 2), 0.9)) +class _FakeBatchRankingPromptService: + """Service double that returns rankable SEGS for each image.""" + + def __init__(self) -> None: + """Create an empty call counter.""" + + self.call_count = 0 + + def prompt(self, **kwargs: object) -> tuple[tuple[int, int], tuple[Segment, ...]]: + """Return high and low confidence prompted regions for one image.""" + + assert cast(torch.Tensor, kwargs["image"]).shape == (1, 3, 3, 3) + label_prefix = f"image-{self.call_count}" + self.call_count += 1 + return _segs( + _segment(f"{label_prefix}-low", CropRegion(0, 0, 2, 2), 0.1), + _segment(f"{label_prefix}-high", CropRegion(1, 1, 2, 2), 0.9), + ) + + class _FixedCombinedBuilder: """Combined-result builder double with input assertions.""" @@ -251,6 +395,7 @@ class _FixedCombinedBuilder: self._expected_segs = expected_segs self._combined = combined self.call_count = 0 + self.seen_labels: list[list[str]] = [] def __call__( self, @@ -262,7 +407,11 @@ class _FixedCombinedBuilder: self.call_count += 1 assert torch.equal(cast(torch.Tensor, source_image), self._expected_image) - assert source_segs is self._expected_segs + self.seen_labels.append([segment.label for segment in source_segs[1]]) + assert source_segs[0] == self._expected_segs[0] + assert self.seen_labels[-1] == [ + segment.label for segment in self._expected_segs[1] + ] assert crop_factor == 3.0 return self._combined @@ -305,6 +454,9 @@ def _prompt_with_fakes( image: torch.Tensor, combine_segs: bool, vitmatte_model: object | None = None, + keep_only: int = 0, + keep_by: str = "highest confidence", + sort_order: str = "largest to smallest", ) -> tuple[object, object]: """Run the prompt node with temporary service and builder doubles.""" @@ -322,6 +474,8 @@ def _prompt_with_fakes( negative_prompt="", confidence_threshold=0.3, size_threshold=10, + keep_only=keep_only, + keep_by=keep_by, bbox_dilation=0, mask_dilation=0, detail_method="GuidedFilter", @@ -333,7 +487,7 @@ def _prompt_with_fakes( mask_refinement_max_size=2048, execution_device="cpu", crop_factor=3.0, - sort_order="largest to smallest", + sort_order=sort_order, combine_segs=combine_segs, vitmatte_model=vitmatte_model, ) @@ -348,6 +502,19 @@ def _segs(*segments: Segment) -> tuple[tuple[int, int], tuple[Segment, ...]]: return (3, 3), tuple(segments) +def _ranking_segs() -> tuple[tuple[int, int], tuple[Segment, ...]]: + """Create rankable native SEGS for limiting tests.""" + + return ( + (3, 3), + ( + _segment("small-high", CropRegion(0, 0, 2, 2), 0.95), + _segment("large-low", CropRegion(4, 0, 8, 4), 0.1), + _segment("medium", CropRegion(2, 0, 5, 3), 0.5), + ), + ) + + def _segment(label: str, crop_region: CropRegion, confidence: float) -> Segment: """Create a test segment.""" diff --git a/tests/test_prompt_segs_with_sam_service.py b/tests/test_prompt_segs_with_sam_service.py index 38e285c..3cc528b 100644 --- a/tests/test_prompt_segs_with_sam_service.py +++ b/tests/test_prompt_segs_with_sam_service.py @@ -301,8 +301,8 @@ def test_service_vitmatte_refinement_receives_max_size() -> None: assert refiner.calls == [512] -def test_service_sorts_final_segs() -> None: - """Final SEGS use the requested shared sorting policy.""" +def test_service_preserves_detection_order_for_node_sorting() -> None: + """Final SEGS remain unsorted so the node owns output ordering.""" detector = RecordingDetector( { @@ -321,10 +321,9 @@ def test_service_sorts_final_segs() -> None: service, positive_prompt="face", size_threshold=1, - sort_order="highest confidence first", ) - assert [segment.confidence for segment in segs[1]] == [0.9, 0.5] + assert [segment.confidence for segment in segs[1]] == [0.5, 0.9] def _service( @@ -358,7 +357,6 @@ def _prompt( mask_refinement_max_size: int = 2048, execution_device: str = "cpu", crop_factor: float = 1.0, - sort_order: str = "largest to smallest", ) -> NativeSegs: """Call the service with common test options.""" @@ -382,7 +380,6 @@ def _prompt( mask_refinement_max_size=mask_refinement_max_size, execution_device=execution_device, crop_factor=crop_factor, - sort_order=sort_order, ) diff --git a/tests/test_segs_domain.py b/tests/test_segs_domain.py index 1f8447b..bbfce9f 100644 --- a/tests/test_segs_domain.py +++ b/tests/test_segs_domain.py @@ -11,6 +11,7 @@ from collections import namedtuple import pytest from simple_syrup.domain.segs import ( + KEEP_BY_OPTIONS, SORT_ORDER_OPTIONS, BoundingBox, CropRegion, @@ -18,6 +19,7 @@ from simple_syrup.domain.segs import ( coerce_segment, coerce_segs, coerce_segs_group, + limit_segs, sort_segs, to_impact_compatible_segs, to_impact_compatible_segs_group, @@ -172,6 +174,112 @@ def test_sort_order_options_are_plain_english_and_ordered() -> None: ) +def test_keep_by_options_are_plain_english_and_ordered() -> None: + """SEGS limiting options match the detector node combo contract.""" + + assert KEEP_BY_OPTIONS == ("highest confidence", "largest size") + + +def test_limit_segs_zero_keeps_all_segments() -> None: + """Keep-only zero preserves all detected regions.""" + + first = _segment("first", CropRegion(0, 0, 2, 2), 0.2) + second = _segment("second", CropRegion(2, 2, 5, 5), 0.9) + + _header, segments = limit_segs( + ((16, 16), (first, second)), + keep_only=0, + keep_by="highest confidence", + ) + + assert segments == (first, second) + + +def test_limit_segs_keeps_highest_confidence_segments() -> None: + """Confidence limiting keeps the strongest detections.""" + + lower = _segment("lower", CropRegion(0, 0, 2, 2), 0.2) + higher = _segment("higher", CropRegion(2, 2, 4, 4), 0.9) + middle = _segment("middle", CropRegion(4, 4, 6, 6), 0.5) + + _header, segments = limit_segs( + ((16, 16), (lower, higher, middle)), + keep_only=2, + keep_by="highest confidence", + ) + + assert [segment.label for segment in segments] == ["higher", "middle"] + + +def test_limit_segs_keeps_largest_size_segments() -> None: + """Size limiting keeps the largest crop regions.""" + + small = _segment("small", CropRegion(0, 0, 2, 2), 0.9) + large = _segment("large", CropRegion(2, 2, 8, 8), 0.2) + medium = _segment("medium", CropRegion(8, 8, 12, 11), 0.5) + + _header, segments = limit_segs( + ((16, 16), (small, large, medium)), + keep_only=2, + keep_by="largest size", + ) + + assert [segment.label for segment in segments] == ["large", "medium"] + + +def test_limit_segs_restores_original_order_after_selection() -> None: + """Limiting chooses by rank but leaves final ordering to sort_order.""" + + first = _segment("first", CropRegion(0, 0, 2, 2), 0.8) + second = _segment("second", CropRegion(2, 2, 4, 4), 0.1) + third = _segment("third", CropRegion(4, 4, 6, 6), 0.7) + + _header, segments = limit_segs( + ((16, 16), (first, second, third)), + keep_only=2, + keep_by="highest confidence", + ) + + assert [segment.label for segment in segments] == ["first", "third"] + + +def test_limit_segs_keeps_all_when_limit_exceeds_segment_count() -> None: + """Oversized keep-only values keep every available segment.""" + + first = _segment("first", CropRegion(0, 0, 2, 2), 0.2) + second = _segment("second", CropRegion(2, 2, 5, 5), 0.9) + + _header, segments = limit_segs( + ((16, 16), (first, second)), + keep_only=5, + keep_by="largest size", + ) + + assert segments == (first, second) + + +def test_limit_segs_rejects_negative_keep_only() -> None: + """Negative keep-only values fail instead of changing selection semantics.""" + + with pytest.raises(ValueError, match="keep_only"): + limit_segs( + ((16, 16), (_segment("first", CropRegion(0, 0, 2, 2), 0.2),)), + keep_only=-1, + keep_by="highest confidence", + ) + + +def test_limit_segs_rejects_unknown_keep_by() -> None: + """Unknown keep-by options fail instead of silently changing behavior.""" + + with pytest.raises(ValueError, match="Unknown SEGS keep_by option"): + limit_segs( + ((16, 16), (_segment("first", CropRegion(0, 0, 2, 2), 0.2),)), + keep_only=1, + keep_by="smallest size", + ) + + @pytest.mark.parametrize( ("sort_order", "expected_labels"), [ From b07ebcb7d50b3969789139382522df60c51e3579 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Fri, 22 May 2026 23:35:35 -0400 Subject: [PATCH 05/40] fix: use SimpleSyrup package identity --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f7a5bf8..c5a2452 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] -name = "simple-syrup" +name = "SimpleSyrup" description = "Workflow-focused ComfyUI extensions for image generation." version = "1.0.0" license = "AGPL-3.0-or-later" From 5f22072939ab29285ac0af54a576db981e1dc056 Mon Sep 17 00:00:00 2001 From: Daisy Date: Sat, 23 May 2026 03:45:00 +0000 Subject: [PATCH 06/40] 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 ([74c83a6](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/74c83a6a70407aea6a34b0c0e31408f32b929882)) * use SimpleSyrup package identity ([4fa5582](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4fa5582796e9ace2a8805cb1ea2aebce65551e89)) ### Features * **detection:** add keep-only SEGS selection ([8f8ee91](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/8f8ee91dea3ce1a044c0e61b482e571c51b372bc)) --- CHANGELOG.md | 13 +++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- pyproject.toml | 2 +- simple_syrup/__init__.py | 2 +- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47430e0..958e718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# [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 ([74c83a6](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/74c83a6a70407aea6a34b0c0e31408f32b929882)) +* use SimpleSyrup package identity ([4fa5582](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4fa5582796e9ace2a8805cb1ea2aebce65551e89)) + + +### Features + +* **detection:** add keep-only SEGS selection ([8f8ee91](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/8f8ee91dea3ce1a044c0e61b482e571c51b372bc)) + # 1.0.0 (2026-05-22) diff --git a/package-lock.json b/package-lock.json index 206dbb5..4cf67d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-syrup-comfyui", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-syrup-comfyui", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0-or-later", "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/package.json b/package.json index 47f2869..77d2dfc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-syrup-comfyui", - "version": "1.0.0", + "version": "1.1.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index c5a2452..4e599a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "SimpleSyrup" description = "Workflow-focused ComfyUI extensions for image generation." -version = "1.0.0" +version = "1.1.0" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] requires-python = ">=3.11" diff --git a/simple_syrup/__init__.py b/simple_syrup/__init__.py index d802de7..7c2aa87 100644 --- a/simple_syrup/__init__.py +++ b/simple_syrup/__init__.py @@ -6,6 +6,6 @@ from __future__ import annotations -__version__ = "1.0.0" +__version__ = "1.1.0" __all__: list[str] = ["__version__"] From 51f4b8464e366be5dbe5f0bc140ac0870a779861 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Mon, 25 May 2026 13:40:36 -0400 Subject: [PATCH 07/40] 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. --- simple_syrup/nodes/__init__.py | 7 + simple_syrup/nodes/tooltips.py | 21 ++ simple_syrup/nodes/vae_options.py | 248 ++++++++++++++++++ simple_syrup/nodes_v3/__init__.py | 6 + simple_syrup/nodes_v3/vae_decode_options.py | 143 ++++++++++ simple_syrup/nodes_v3/vae_encode_options.py | 143 ++++++++++ .../runtime/comfy_graph_provenance.py | 17 +- simple_syrup/runtime/detail_sampling.py | 17 +- .../runtime/differential_diffusion.py | 50 ++++ .../runtime/mixture_of_diffusers_sampling.py | 6 + .../runtime/multidiffusion_sampling.py | 6 + .../regional_multidiffusion_sampling.py | 6 + simple_syrup/runtime/vae_options_graph.py | 115 ++++++++ .../detail_segs_as_regions_service.py | 18 +- ...by_scale_factor_tiled_diffusion_service.py | 21 +- .../tiled_diffusion_sampling_service.py | 3 + tests/test_detail_segs_as_regions_service.py | 11 +- ...by_scale_factor_tiled_diffusion_service.py | 29 ++ tests/test_graph_provenance.py | 65 +++++ tests/test_mixture_of_diffusers_sampling.py | 36 ++- tests/test_multidiffusion_sampling.py | 36 ++- tests/test_node_tooltips.py | 4 + .../test_regional_multidiffusion_sampling.py | 36 ++- tests/test_registration.py | 27 ++ .../test_tiled_diffusion_sampling_service.py | 30 +++ tests/test_vae_decode_options_v3_node.py | 78 ++++++ tests/test_vae_encode_options_v3_node.py | 78 ++++++ tests/test_vae_options_node.py | 226 ++++++++++++++++ 28 files changed, 1432 insertions(+), 51 deletions(-) create mode 100644 simple_syrup/nodes/vae_options.py create mode 100644 simple_syrup/nodes_v3/vae_decode_options.py create mode 100644 simple_syrup/nodes_v3/vae_encode_options.py create mode 100644 simple_syrup/runtime/differential_diffusion.py create mode 100644 simple_syrup/runtime/vae_options_graph.py create mode 100644 tests/test_vae_decode_options_v3_node.py create mode 100644 tests/test_vae_encode_options_v3_node.py create mode 100644 tests/test_vae_options_node.py diff --git a/simple_syrup/nodes/__init__.py b/simple_syrup/nodes/__init__.py index e9fe747..c06593d 100644 --- a/simple_syrup/nodes/__init__.py +++ b/simple_syrup/nodes/__init__.py @@ -32,6 +32,7 @@ from .seed import Seed from .simple_load_anima import SimpleLoadAnima from .simple_load_checkpoint import SimpleLoadCheckpoint from .tile_and_tag_segs import TileAndTagSEGS +from .vae_options import VAEDecodeOptions, VAEEncodeOptions from .vitmatte_model_loader import ViTMatteModelLoader from .wd14_tagger_loader import WD14TaggerLoader @@ -49,6 +50,8 @@ NODE_CLASS_MAPPINGS = { "SimpleSyrup.PromptSEGSWithSAM": PromptSEGSWithSAM, "SimpleSyrup.SimpleVAEEncode": SimpleVAEEncode, "SimpleSyrup.UpscaleLatentFromImage": UpscaleLatentFromImage, + "SimpleSyrup.VAEDecodeOptions": VAEDecodeOptions, + "SimpleSyrup.VAEEncodeOptions": VAEEncodeOptions, "SimpleSyrup.ResizeImageToTarget": ResizeImageToTarget, "SimpleSyrup.DetailSEGSAsRegions": DetailSEGSAsRegions, "SimpleSyrup.DetailSEGSByScaleFactor": DetailSEGSByScaleFactor, @@ -84,6 +87,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SimpleSyrup.PromptSEGSWithSAM": "Prompt SEGS w/ SAM", "SimpleSyrup.SimpleVAEEncode": "Simple VAE Encode", "SimpleSyrup.UpscaleLatentFromImage": "Upscale Latent From Image", + "SimpleSyrup.VAEDecodeOptions": "VAE Decode (Options)", + "SimpleSyrup.VAEEncodeOptions": "VAE Encode (Options)", "SimpleSyrup.ResizeImageToTarget": "Resize Image to Target", "SimpleSyrup.DetailSEGSAsRegions": "Detail SEGS as Regions", "SimpleSyrup.DetailSEGSByScaleFactor": "Detail SEGS by Scale Factor", @@ -132,6 +137,8 @@ __all__ = [ "SimpleVAEEncode", "TileAndTagSEGS", "UpscaleLatentFromImage", + "VAEDecodeOptions", + "VAEEncodeOptions", "ViTMatteModelLoader", "WD14TaggerLoader", ] diff --git a/simple_syrup/nodes/tooltips.py b/simple_syrup/nodes/tooltips.py index a7b6454..da145b4 100644 --- a/simple_syrup/nodes/tooltips.py +++ b/simple_syrup/nodes/tooltips.py @@ -23,6 +23,27 @@ MODEL_OUTPUT = "Loaded diffusion model for downstream MODEL inputs." CLIP_OUTPUT = "Loaded text encoder for downstream CLIP inputs." VAE_OUTPUT = "Loaded VAE used to encode images to latents and decode latents to images." +VAE_OPTIONS_USE_TILING = ( + "Use ComfyUI's tiled VAE node. Disabled uses normal ComfyUI VAE behavior, " + "including its automatic tiled retry after out-of-memory." +) +VAE_OPTIONS_ENCODE_PIXELS = "Image to encode into latent space." +VAE_OPTIONS_DECODE_SAMPLES = "Latent samples to decode into an image." +VAE_OPTIONS_VAE = "VAE used for the selected encode or decode operation." +VAE_OPTIONS_TILE_SIZE = ( + "Tile size in pixels. Larger tiles are faster but use more memory." +) +VAE_OPTIONS_OVERLAP = ( + "Overlap between tiles in pixels. Larger overlaps reduce seams but do more work." +) +VAE_OPTIONS_ENCODE_TEMPORAL_SIZE = "For video VAEs, number of frames to encode at once." +VAE_OPTIONS_DECODE_TEMPORAL_SIZE = "For video VAEs, number of frames to decode at once." +VAE_OPTIONS_TEMPORAL_OVERLAP = ( + "For video VAEs, number of overlapping frames between temporal tiles." +) +VAE_OPTIONS_LATENT_OUTPUT = "Latent produced by ComfyUI's selected VAE encode node." +VAE_OPTIONS_IMAGE_OUTPUT = "Image produced by ComfyUI's selected VAE decode node." + SAM_MODEL_OUTPUT = "Loaded SAM model for prompt-based mask and SEGS creation." GROUNDING_DINO_MODEL_OUTPUT = ( "Loaded GroundingDINO model for finding prompt-matched boxes in images." diff --git a/simple_syrup/nodes/vae_options.py b/simple_syrup/nodes/vae_options.py new file mode 100644 index 0000000..d37c20c --- /dev/null +++ b/simple_syrup/nodes/vae_options.py @@ -0,0 +1,248 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI nodes that switch between native normal and tiled VAE execution.""" + +from __future__ import annotations + +from typing import Any + +from ..runtime.vae_options_graph import ExpansionResult, VAEOptionsGraphBuilder +from . import tooltips + +VAE_TILE_SIZE_DEFAULT = 512 +VAE_TILE_SIZE_MIN = 64 +VAE_TILE_SIZE_MAX = 4096 +VAE_ENCODE_TILE_SIZE_STEP = 64 +VAE_DECODE_TILE_SIZE_STEP = 32 +VAE_OVERLAP_DEFAULT = 64 +VAE_OVERLAP_MIN = 0 +VAE_OVERLAP_MAX = 4096 +VAE_OVERLAP_STEP = 32 +VAE_TEMPORAL_SIZE_DEFAULT = 64 +VAE_TEMPORAL_SIZE_MIN = 8 +VAE_TEMPORAL_SIZE_MAX = 4096 +VAE_TEMPORAL_SIZE_STEP = 4 +VAE_TEMPORAL_OVERLAP_DEFAULT = 8 +VAE_TEMPORAL_OVERLAP_MIN = 4 +VAE_TEMPORAL_OVERLAP_MAX = 4096 +VAE_TEMPORAL_OVERLAP_STEP = 4 + + +class VAEEncodeOptions: + """Encode images through ComfyUI's normal or tiled VAE encode nodes.""" + + RETURN_TYPES = ("LATENT",) + RETURN_NAMES = ("latent",) + OUTPUT_TOOLTIPS = (tooltips.VAE_OPTIONS_LATENT_OUTPUT,) + FUNCTION = "encode" + CATEGORY = "SimpleSyrup/Latent" + DESCRIPTION = ( + "Encodes images to latent space with selectable normal or tiled VAE execution." + ) + SEARCH_ALIASES = [ + "vae encode", + "encode image", + "tiled vae encode", + "image to latent", + ] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare VAE encode inputs and tiled execution controls.""" + + return { + "required": { + "use_tiling": _use_tiling_input(), + "pixels": ( + "IMAGE", + { + "rawLink": True, + "tooltip": tooltips.VAE_OPTIONS_ENCODE_PIXELS, + }, + ), + "vae": _vae_input(), + "tile_size": _tile_size_input(VAE_ENCODE_TILE_SIZE_STEP), + "overlap": _overlap_input(), + "temporal_size": _temporal_size_input( + tooltips.VAE_OPTIONS_ENCODE_TEMPORAL_SIZE + ), + "temporal_overlap": _temporal_overlap_input(), + }, + } + + def encode( + self, + use_tiling: bool, + pixels: object, + vae: object, + tile_size: int, + overlap: int, + temporal_size: int = VAE_TEMPORAL_SIZE_DEFAULT, + temporal_overlap: int = VAE_TEMPORAL_OVERLAP_DEFAULT, + ) -> ExpansionResult: + """Expand to ComfyUI's selected native VAE encode node.""" + + return VAEOptionsGraphBuilder().build_encode( + pixels=pixels, + vae=vae, + use_tiling=use_tiling, + tile_size=tile_size, + overlap=overlap, + temporal_size=temporal_size, + temporal_overlap=temporal_overlap, + ) + + +class VAEDecodeOptions: + """Decode latents through ComfyUI's normal or tiled VAE decode nodes.""" + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + OUTPUT_TOOLTIPS = (tooltips.VAE_OPTIONS_IMAGE_OUTPUT,) + FUNCTION = "decode" + CATEGORY = "SimpleSyrup/Latent" + DESCRIPTION = ( + "Decodes latents to images with selectable normal or tiled VAE execution." + ) + SEARCH_ALIASES = [ + "vae decode", + "decode latent", + "tiled vae decode", + "latent to image", + ] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare VAE decode inputs and tiled execution controls.""" + + return { + "required": { + "use_tiling": _use_tiling_input(), + "samples": ( + "LATENT", + { + "rawLink": True, + "tooltip": tooltips.VAE_OPTIONS_DECODE_SAMPLES, + }, + ), + "vae": _vae_input(), + "tile_size": _tile_size_input(VAE_DECODE_TILE_SIZE_STEP), + "overlap": _overlap_input(), + "temporal_size": _temporal_size_input( + tooltips.VAE_OPTIONS_DECODE_TEMPORAL_SIZE + ), + "temporal_overlap": _temporal_overlap_input(), + }, + } + + def decode( + self, + use_tiling: bool, + samples: object, + vae: object, + tile_size: int, + overlap: int, + temporal_size: int = VAE_TEMPORAL_SIZE_DEFAULT, + temporal_overlap: int = VAE_TEMPORAL_OVERLAP_DEFAULT, + ) -> ExpansionResult: + """Expand to ComfyUI's selected native VAE decode node.""" + + return VAEOptionsGraphBuilder().build_decode( + samples=samples, + vae=vae, + use_tiling=use_tiling, + tile_size=tile_size, + overlap=overlap, + temporal_size=temporal_size, + temporal_overlap=temporal_overlap, + ) + + +def _use_tiling_input() -> tuple[str, dict[str, object]]: + """Return the shared tiling toggle declaration.""" + + return ( + "BOOLEAN", + { + "default": False, + "tooltip": tooltips.VAE_OPTIONS_USE_TILING, + }, + ) + + +def _vae_input() -> tuple[str, dict[str, object]]: + """Return the shared raw-link VAE input declaration.""" + + return ( + "VAE", + { + "rawLink": True, + "tooltip": tooltips.VAE_OPTIONS_VAE, + }, + ) + + +def _tile_size_input(step: int) -> tuple[str, dict[str, object]]: + """Return the tile-size input declaration for encode or decode.""" + + return ( + "INT", + { + "default": VAE_TILE_SIZE_DEFAULT, + "min": VAE_TILE_SIZE_MIN, + "max": VAE_TILE_SIZE_MAX, + "step": step, + "advanced": True, + "tooltip": tooltips.VAE_OPTIONS_TILE_SIZE, + }, + ) + + +def _overlap_input() -> tuple[str, dict[str, object]]: + """Return the shared tile-overlap input declaration.""" + + return ( + "INT", + { + "default": VAE_OVERLAP_DEFAULT, + "min": VAE_OVERLAP_MIN, + "max": VAE_OVERLAP_MAX, + "step": VAE_OVERLAP_STEP, + "advanced": True, + "tooltip": tooltips.VAE_OPTIONS_OVERLAP, + }, + ) + + +def _temporal_size_input(tooltip: str) -> tuple[str, dict[str, object]]: + """Return the shared temporal tile-size input declaration.""" + + return ( + "INT", + { + "default": VAE_TEMPORAL_SIZE_DEFAULT, + "min": VAE_TEMPORAL_SIZE_MIN, + "max": VAE_TEMPORAL_SIZE_MAX, + "step": VAE_TEMPORAL_SIZE_STEP, + "advanced": True, + "tooltip": tooltip, + }, + ) + + +def _temporal_overlap_input() -> tuple[str, dict[str, object]]: + """Return the shared temporal overlap input declaration.""" + + return ( + "INT", + { + "default": VAE_TEMPORAL_OVERLAP_DEFAULT, + "min": VAE_TEMPORAL_OVERLAP_MIN, + "max": VAE_TEMPORAL_OVERLAP_MAX, + "step": VAE_TEMPORAL_OVERLAP_STEP, + "advanced": True, + "tooltip": tooltips.VAE_OPTIONS_TEMPORAL_OVERLAP, + }, + ) diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index 2733f56..fd6446e 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -15,6 +15,8 @@ def get_nodes() -> list[type[object]]: from .scale_factor import ScaleFactorV3 from .simple_load_checkpoint import SimpleLoadCheckpointV3 from .tile_and_tag_segs import TileAndTagSEGSV3 + from .vae_decode_options import VAEDecodeOptionsV3 + from .vae_encode_options import VAEEncodeOptionsV3 from .wd14_tagger_loader import WD14TaggerLoaderV3 if not prompt_control_is_available(): @@ -23,6 +25,8 @@ def get_nodes() -> list[type[object]]: TileAndTagSEGSV3, SimpleLoadCheckpointV3, ScaleFactorV3, + VAEDecodeOptionsV3, + VAEEncodeOptionsV3, ] from .encode_prompt_batch_with_prompt_control import ( @@ -34,6 +38,8 @@ def get_nodes() -> list[type[object]]: TileAndTagSEGSV3, SimpleLoadCheckpointV3, ScaleFactorV3, + VAEDecodeOptionsV3, + VAEEncodeOptionsV3, EncodePromptBatchWithPromptControl, ] diff --git a/simple_syrup/nodes_v3/vae_decode_options.py b/simple_syrup/nodes_v3/vae_decode_options.py new file mode 100644 index 0000000..71270e4 --- /dev/null +++ b/simple_syrup/nodes_v3/vae_decode_options.py @@ -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 + +"""Comfy v3 node wrapper for VAE Decode (Options).""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes import tooltips +from ..nodes.vae_options import ( + VAE_DECODE_TILE_SIZE_STEP, + VAE_OVERLAP_DEFAULT, + VAE_OVERLAP_MAX, + VAE_OVERLAP_MIN, + VAE_OVERLAP_STEP, + VAE_TEMPORAL_OVERLAP_DEFAULT, + VAE_TEMPORAL_OVERLAP_MAX, + VAE_TEMPORAL_OVERLAP_MIN, + VAE_TEMPORAL_OVERLAP_STEP, + VAE_TEMPORAL_SIZE_DEFAULT, + VAE_TEMPORAL_SIZE_MAX, + VAE_TEMPORAL_SIZE_MIN, + VAE_TEMPORAL_SIZE_STEP, + VAE_TILE_SIZE_DEFAULT, + VAE_TILE_SIZE_MAX, + VAE_TILE_SIZE_MIN, + VAEDecodeOptions, +) + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class VAEDecodeOptionsV3(_ComfyNodeBase): + """Expose VAE Decode (Options) through Comfy's v3 extension API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the VAE Decode (Options) v3 schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.VAEDecodeOptions", + display_name="VAE Decode (Options)", + enable_expand=True, + category="SimpleSyrup/Latent", + description=VAEDecodeOptions.DESCRIPTION, + search_aliases=VAEDecodeOptions.SEARCH_ALIASES, + inputs=[ + _comfy_io.Boolean.Input( + "use_tiling", + default=False, + tooltip=tooltips.VAE_OPTIONS_USE_TILING, + ), + _comfy_io.Latent.Input( + "samples", + raw_link=True, + tooltip=tooltips.VAE_OPTIONS_DECODE_SAMPLES, + ), + _comfy_io.Vae.Input( + "vae", + raw_link=True, + tooltip=tooltips.VAE_OPTIONS_VAE, + ), + _comfy_io.Int.Input( + "tile_size", + default=VAE_TILE_SIZE_DEFAULT, + min=VAE_TILE_SIZE_MIN, + max=VAE_TILE_SIZE_MAX, + step=VAE_DECODE_TILE_SIZE_STEP, + advanced=True, + tooltip=tooltips.VAE_OPTIONS_TILE_SIZE, + ), + _comfy_io.Int.Input( + "overlap", + default=VAE_OVERLAP_DEFAULT, + min=VAE_OVERLAP_MIN, + max=VAE_OVERLAP_MAX, + step=VAE_OVERLAP_STEP, + advanced=True, + tooltip=tooltips.VAE_OPTIONS_OVERLAP, + ), + _comfy_io.Int.Input( + "temporal_size", + default=VAE_TEMPORAL_SIZE_DEFAULT, + min=VAE_TEMPORAL_SIZE_MIN, + max=VAE_TEMPORAL_SIZE_MAX, + step=VAE_TEMPORAL_SIZE_STEP, + advanced=True, + tooltip=tooltips.VAE_OPTIONS_DECODE_TEMPORAL_SIZE, + ), + _comfy_io.Int.Input( + "temporal_overlap", + default=VAE_TEMPORAL_OVERLAP_DEFAULT, + min=VAE_TEMPORAL_OVERLAP_MIN, + max=VAE_TEMPORAL_OVERLAP_MAX, + step=VAE_TEMPORAL_OVERLAP_STEP, + advanced=True, + tooltip=tooltips.VAE_OPTIONS_TEMPORAL_OVERLAP, + ), + ], + outputs=[ + _comfy_io.Image.Output( + "image", + tooltip=tooltips.VAE_OPTIONS_IMAGE_OUTPUT, + ), + ], + ) + + @classmethod + def execute( + cls, + use_tiling: bool, + samples: object, + vae: object, + tile_size: int, + overlap: int, + temporal_size: int, + temporal_overlap: int, + ) -> Any: + """Expand through the legacy VAE Decode (Options) implementation.""" + + return VAEDecodeOptions().decode( + use_tiling=use_tiling, + samples=samples, + vae=vae, + tile_size=tile_size, + overlap=overlap, + temporal_size=temporal_size, + temporal_overlap=temporal_overlap, + ) diff --git a/simple_syrup/nodes_v3/vae_encode_options.py b/simple_syrup/nodes_v3/vae_encode_options.py new file mode 100644 index 0000000..1c7714c --- /dev/null +++ b/simple_syrup/nodes_v3/vae_encode_options.py @@ -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 + +"""Comfy v3 node wrapper for VAE Encode (Options).""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes import tooltips +from ..nodes.vae_options import ( + VAE_ENCODE_TILE_SIZE_STEP, + VAE_OVERLAP_DEFAULT, + VAE_OVERLAP_MAX, + VAE_OVERLAP_MIN, + VAE_OVERLAP_STEP, + VAE_TEMPORAL_OVERLAP_DEFAULT, + VAE_TEMPORAL_OVERLAP_MAX, + VAE_TEMPORAL_OVERLAP_MIN, + VAE_TEMPORAL_OVERLAP_STEP, + VAE_TEMPORAL_SIZE_DEFAULT, + VAE_TEMPORAL_SIZE_MAX, + VAE_TEMPORAL_SIZE_MIN, + VAE_TEMPORAL_SIZE_STEP, + VAE_TILE_SIZE_DEFAULT, + VAE_TILE_SIZE_MAX, + VAE_TILE_SIZE_MIN, + VAEEncodeOptions, +) + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class VAEEncodeOptionsV3(_ComfyNodeBase): + """Expose VAE Encode (Options) through Comfy's v3 extension API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the VAE Encode (Options) v3 schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.VAEEncodeOptions", + display_name="VAE Encode (Options)", + enable_expand=True, + category="SimpleSyrup/Latent", + description=VAEEncodeOptions.DESCRIPTION, + search_aliases=VAEEncodeOptions.SEARCH_ALIASES, + inputs=[ + _comfy_io.Boolean.Input( + "use_tiling", + default=False, + tooltip=tooltips.VAE_OPTIONS_USE_TILING, + ), + _comfy_io.Image.Input( + "pixels", + raw_link=True, + tooltip=tooltips.VAE_OPTIONS_ENCODE_PIXELS, + ), + _comfy_io.Vae.Input( + "vae", + raw_link=True, + tooltip=tooltips.VAE_OPTIONS_VAE, + ), + _comfy_io.Int.Input( + "tile_size", + default=VAE_TILE_SIZE_DEFAULT, + min=VAE_TILE_SIZE_MIN, + max=VAE_TILE_SIZE_MAX, + step=VAE_ENCODE_TILE_SIZE_STEP, + advanced=True, + tooltip=tooltips.VAE_OPTIONS_TILE_SIZE, + ), + _comfy_io.Int.Input( + "overlap", + default=VAE_OVERLAP_DEFAULT, + min=VAE_OVERLAP_MIN, + max=VAE_OVERLAP_MAX, + step=VAE_OVERLAP_STEP, + advanced=True, + tooltip=tooltips.VAE_OPTIONS_OVERLAP, + ), + _comfy_io.Int.Input( + "temporal_size", + default=VAE_TEMPORAL_SIZE_DEFAULT, + min=VAE_TEMPORAL_SIZE_MIN, + max=VAE_TEMPORAL_SIZE_MAX, + step=VAE_TEMPORAL_SIZE_STEP, + advanced=True, + tooltip=tooltips.VAE_OPTIONS_ENCODE_TEMPORAL_SIZE, + ), + _comfy_io.Int.Input( + "temporal_overlap", + default=VAE_TEMPORAL_OVERLAP_DEFAULT, + min=VAE_TEMPORAL_OVERLAP_MIN, + max=VAE_TEMPORAL_OVERLAP_MAX, + step=VAE_TEMPORAL_OVERLAP_STEP, + advanced=True, + tooltip=tooltips.VAE_OPTIONS_TEMPORAL_OVERLAP, + ), + ], + outputs=[ + _comfy_io.Latent.Output( + "latent", + tooltip=tooltips.VAE_OPTIONS_LATENT_OUTPUT, + ), + ], + ) + + @classmethod + def execute( + cls, + use_tiling: bool, + pixels: object, + vae: object, + tile_size: int, + overlap: int, + temporal_size: int, + temporal_overlap: int, + ) -> Any: + """Expand through the legacy VAE Encode (Options) implementation.""" + + return VAEEncodeOptions().encode( + use_tiling=use_tiling, + pixels=pixels, + vae=vae, + tile_size=tile_size, + overlap=overlap, + temporal_size=temporal_size, + temporal_overlap=temporal_overlap, + ) diff --git a/simple_syrup/runtime/comfy_graph_provenance.py b/simple_syrup/runtime/comfy_graph_provenance.py index 1828336..d2b46e4 100644 --- a/simple_syrup/runtime/comfy_graph_provenance.py +++ b/simple_syrup/runtime/comfy_graph_provenance.py @@ -18,6 +18,12 @@ from ..domain.graph_provenance import ( MAX_PROVENANCE_HOPS = 128 PASSTHROUGH_ATTRIBUTE = "GRAPH_PASSTHROUGH_OUTPUTS" +VAE_DECODE_SOURCE_CLASS_TYPES = frozenset( + { + "VAEDecode", + "SimpleSyrup.VAEDecodeOptions", + } +) PromptNode = Mapping[str, Any] PromptGraph = Mapping[str, Any] @@ -67,8 +73,8 @@ def trace_vae_decode_provenance( class_type=class_type, ) - if class_type == "VAEDecode": - return _trace_vae_decode(node_id, output_slot, inputs) + if class_type in VAE_DECODE_SOURCE_CLASS_TYPES: + return _trace_vae_decode(node_id, output_slot, inputs, class_type) class_def = node_registry.get(class_type) if class_def is None: @@ -135,15 +141,16 @@ def _trace_vae_decode( node_id: str, output_slot: int, inputs: Mapping[str, Any], + class_type: str, ) -> ProvenanceTrace: - """Resolve the latent and VAE links from a `VAEDecode` prompt node.""" + """Resolve latent and VAE links from a trusted VAE decode prompt node.""" image_output = (node_id, output_slot) if output_slot != 0: return BrokenProvenance( "VAEDecode output is not the image output", node_id=node_id, - class_type="VAEDecode", + class_type=class_type, ) samples_link = parse_graph_link(inputs.get("samples")) @@ -151,7 +158,7 @@ def _trace_vae_decode( return BrokenProvenance( "VAEDecode samples input is not a graph link", node_id=node_id, - class_type="VAEDecode", + class_type=class_type, ) return VaeDecodeProvenance( diff --git a/simple_syrup/runtime/detail_sampling.py b/simple_syrup/runtime/detail_sampling.py index 675df09..0044cc7 100644 --- a/simple_syrup/runtime/detail_sampling.py +++ b/simple_syrup/runtime/detail_sampling.py @@ -13,6 +13,7 @@ import torch from . import sampling_samplers, sampling_schedulers from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback +from .differential_diffusion import clone_with_differential_diffusion Latent: TypeAlias = dict[str, Any] @@ -108,21 +109,7 @@ class DetailSampler: def apply_differential_diffusion(self, model: Any) -> Any: """Patch a model for feathered denoise masks when ComfyUI supports it.""" - options = getattr(model, "model_options", {}) - if ( - isinstance(options, dict) - and options.get("denoise_mask_function") is not None - ): - return model - - module = import_module("comfy_extras.nodes_differential_diffusion") - node = module.DifferentialDiffusion - output = node.execute(model, 1.0) - if hasattr(output, "result"): - return output.result[0] - if isinstance(output, tuple): - return output[0] - return output[0] + return clone_with_differential_diffusion(model) def _nodes() -> Any: diff --git a/simple_syrup/runtime/differential_diffusion.py b/simple_syrup/runtime/differential_diffusion.py new file mode 100644 index 0000000..063a423 --- /dev/null +++ b/simple_syrup/runtime/differential_diffusion.py @@ -0,0 +1,50 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Adapters for ComfyUI differential denoise-mask behavior.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + + +def has_denoise_mask_function(model: Any) -> bool: + """Return whether a model patcher already has denoise-mask behavior.""" + + options = getattr(model, "model_options", {}) + return ( + isinstance(options, dict) and options.get("denoise_mask_function") is not None + ) + + +def clone_with_differential_diffusion(model: Any, strength: float = 1.0) -> Any: + """Return a clone patched with ComfyUI differential denoise masks.""" + + if has_denoise_mask_function(model): + return model + cloned_model = model.clone() + install_differential_diffusion(cloned_model, strength=strength) + return cloned_model + + +def install_differential_diffusion(model: Any, strength: float = 1.0) -> Any: + """Install ComfyUI differential denoise-mask behavior on a model patcher.""" + + if has_denoise_mask_function(model): + return model + set_mask_function = getattr(model, "set_model_denoise_mask_function", None) + if not callable(set_mask_function): + raise ValueError("Model does not support differential diffusion denoise masks.") + differential_diffusion = import_module( + "comfy_extras.nodes_differential_diffusion" + ).DifferentialDiffusion + set_mask_function( + lambda *args, **kwargs: differential_diffusion.forward( + *args, + **kwargs, + strength=strength, + ) + ) + return model diff --git a/simple_syrup/runtime/mixture_of_diffusers_sampling.py b/simple_syrup/runtime/mixture_of_diffusers_sampling.py index 75633ef..dc35eb5 100644 --- a/simple_syrup/runtime/mixture_of_diffusers_sampling.py +++ b/simple_syrup/runtime/mixture_of_diffusers_sampling.py @@ -26,6 +26,7 @@ from ..domain.tiled_diffusion import ( from ..shared.logging import get_logger from . import sampling_samplers, sampling_schedulers from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback +from .differential_diffusion import install_differential_diffusion from .tiled_sampling import ( ApplyModel, Latent, @@ -60,6 +61,7 @@ def sample_mixture_of_diffusers( latent_tile_overlap: int, latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Sample a latent with a cloned model patched for Mixture of Diffusers.""" @@ -105,6 +107,7 @@ def sample_mixture_of_diffusers( tile_height=latent_tile_height, overlap=latent_tile_overlap, tile_batch_size=latent_tile_batch_size, + differential_diffusion=differential_diffusion, ) batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None @@ -160,6 +163,7 @@ def clone_model_with_mixture_of_diffusers( tile_height: int, overlap: int, tile_batch_size: int, + differential_diffusion: bool = False, ) -> tuple[Any, TiledDiffusionPlan]: """Return a model clone patched with a pre-CFG Mixture wrapper.""" @@ -172,6 +176,8 @@ def clone_model_with_mixture_of_diffusers( tile_batch_size=tile_batch_size, ) cloned_model = model.clone() + if differential_diffusion: + install_differential_diffusion(cloned_model) old_wrapper = cloned_model.model_options.get("model_function_wrapper") if old_wrapper is not None and not callable(old_wrapper): raise ValueError("Existing model_function_wrapper is not callable.") diff --git a/simple_syrup/runtime/multidiffusion_sampling.py b/simple_syrup/runtime/multidiffusion_sampling.py index 9956722..472b213 100644 --- a/simple_syrup/runtime/multidiffusion_sampling.py +++ b/simple_syrup/runtime/multidiffusion_sampling.py @@ -25,6 +25,7 @@ from ..domain.tiled_diffusion import ( from ..shared.logging import get_logger from . import sampling_samplers, sampling_schedulers from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback +from .differential_diffusion import install_differential_diffusion from .tiled_sampling import ( ApplyModel, Latent, @@ -60,6 +61,7 @@ def sample_multidiffusion( latent_tile_overlap: int, latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Sample a latent with a cloned model patched for MultiDiffusion.""" @@ -106,6 +108,7 @@ def sample_multidiffusion( tile_height=latent_tile_height, overlap=latent_tile_overlap, tile_batch_size=latent_tile_batch_size, + differential_diffusion=differential_diffusion, ) batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None @@ -162,6 +165,7 @@ def clone_model_with_multidiffusion( tile_height: int, overlap: int, tile_batch_size: int, + differential_diffusion: bool = False, ) -> tuple[Any, TiledDiffusionPlan]: """Return a model clone patched with a pre-CFG MultiDiffusion wrapper.""" @@ -174,6 +178,8 @@ def clone_model_with_multidiffusion( tile_batch_size=tile_batch_size, ) cloned_model = model.clone() + if differential_diffusion: + install_differential_diffusion(cloned_model) old_wrapper = cloned_model.model_options.get("model_function_wrapper") if old_wrapper is not None and not callable(old_wrapper): raise ValueError("Existing model_function_wrapper is not callable.") diff --git a/simple_syrup/runtime/regional_multidiffusion_sampling.py b/simple_syrup/runtime/regional_multidiffusion_sampling.py index 83f5325..fdd0b8c 100644 --- a/simple_syrup/runtime/regional_multidiffusion_sampling.py +++ b/simple_syrup/runtime/regional_multidiffusion_sampling.py @@ -22,6 +22,7 @@ from ..domain.regional_detailing import LatentRegion from ..shared.logging import get_logger from . import sampling_samplers, sampling_schedulers from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback +from .differential_diffusion import install_differential_diffusion from .tiled_sampling import ( Latent, reject_unsupported_conditioning, @@ -62,6 +63,7 @@ def sample_regional_multidiffusion( denoise: float, global_prompt_weight: float, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Sample a latent with regional MultiDiffusion prompt blending.""" @@ -109,6 +111,7 @@ def sample_regional_multidiffusion( latent_ndim=latent_samples.ndim, regions=regions, global_prompt_weight=global_prompt_weight, + differential_diffusion=differential_diffusion, ) batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None @@ -163,6 +166,7 @@ def clone_model_with_regional_multidiffusion( latent_ndim: int, regions: tuple[LatentRegion, ...], global_prompt_weight: float = 0.0, + differential_diffusion: bool = False, ) -> tuple[Any, RegionalMultiDiffusionSummary]: """Return a model clone patched with regional calc-cond-batch blending.""" @@ -177,6 +181,8 @@ def clone_model_with_regional_multidiffusion( regions=regions, ) cloned_model = model.clone() + if differential_diffusion: + install_differential_diffusion(cloned_model) old_wrapper = cloned_model.model_options.get("sampler_calc_cond_batch_function") if old_wrapper is not None and not callable(old_wrapper): raise ValueError("Existing sampler_calc_cond_batch_function is not callable.") diff --git a/simple_syrup/runtime/vae_options_graph.py b/simple_syrup/runtime/vae_options_graph.py new file mode 100644 index 0000000..ce5513d --- /dev/null +++ b/simple_syrup/runtime/vae_options_graph.py @@ -0,0 +1,115 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Build ComfyUI VAE option node expansions without owning VAE execution.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any, TypedDict, cast + + +class ExpansionResult(TypedDict): + """ComfyUI dynamic expansion result with one output link.""" + + expand: dict[str, dict[str, Any]] + result: tuple[list[object], ...] + + +class VAEOptionsGraphBuilder: + """Expand VAE option choices to ComfyUI's native VAE nodes.""" + + def build_encode( + self, + *, + pixels: object, + vae: object, + use_tiling: bool, + tile_size: int, + overlap: int, + temporal_size: int, + temporal_overlap: int, + ) -> ExpansionResult: + """Build a native VAE encode or tiled VAE encode expansion.""" + + inputs: dict[str, object] = { + "pixels": _graph_value(pixels), + "vae": _graph_value(vae), + } + class_type = "VAEEncode" + if use_tiling: + class_type = "VAEEncodeTiled" + inputs.update( + { + "tile_size": int(tile_size), + "overlap": int(overlap), + "temporal_size": int(temporal_size), + "temporal_overlap": int(temporal_overlap), + } + ) + + return _single_node_expansion(class_type, inputs) + + def build_decode( + self, + *, + samples: object, + vae: object, + use_tiling: bool, + tile_size: int, + overlap: int, + temporal_size: int, + temporal_overlap: int, + ) -> ExpansionResult: + """Build a native VAE decode or tiled VAE decode expansion.""" + + inputs: dict[str, object] = { + "samples": _graph_value(samples), + "vae": _graph_value(vae), + } + class_type = "VAEDecode" + if use_tiling: + class_type = "VAEDecodeTiled" + inputs.update( + { + "tile_size": int(tile_size), + "overlap": int(overlap), + "temporal_size": int(temporal_size), + "temporal_overlap": int(temporal_overlap), + } + ) + + return _single_node_expansion(class_type, inputs) + + +def _single_node_expansion( + class_type: str, + inputs: dict[str, object], +) -> ExpansionResult: + """Return a one-node dynamic expansion for a native ComfyUI node.""" + + builder = _graph_builder() + node = builder.node(class_type, **inputs) + return { + "expand": cast(dict[str, dict[str, Any]], builder.finalize()), + "result": (cast(list[object], node.out(0)),), + } + + +def _graph_value(value: object) -> object: + """Normalize tuple graph links to ComfyUI's serialized list shape.""" + + if isinstance(value, tuple) and len(value) == 2: + node_id, output_slot = value + if isinstance(node_id, str) and isinstance(output_slot, int): + return [node_id, output_slot] + return value + + +def _graph_builder() -> Any: + """Return ComfyUI's graph builder without import-time Comfy coupling.""" + + graph_utils = import_module("comfy_execution.graph_utils") + graph_builder = cast(Any, graph_utils.GraphBuilder) + return graph_builder() diff --git a/simple_syrup/services/detail_segs_as_regions_service.py b/simple_syrup/services/detail_segs_as_regions_service.py index ccf6237..9276b0b 100644 --- a/simple_syrup/services/detail_segs_as_regions_service.py +++ b/simple_syrup/services/detail_segs_as_regions_service.py @@ -64,12 +64,10 @@ class RegionalDetailSamplingBoundary(Protocol): denoise: float, global_prompt_weight: float, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Sample one full latent with paired regional conditioning.""" - def apply_differential_diffusion(self, model: Any) -> Any: - """Return a model patched for feathered denoise masks.""" - class RegionalDetailResizeBoundary(Protocol): """Image resize boundary used by regional detailing.""" @@ -133,6 +131,7 @@ class RegionalDetailSampler: denoise: float, global_prompt_weight: float, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Sample one full latent with regional MultiDiffusion.""" @@ -150,13 +149,9 @@ class RegionalDetailSampler: denoise=denoise, global_prompt_weight=global_prompt_weight, preview_context=preview_context, + differential_diffusion=differential_diffusion, ) - def apply_differential_diffusion(self, model: Any) -> Any: - """Patch a model for feathered denoise masks when ComfyUI supports it.""" - - return self._detail_sampler.apply_differential_diffusion(model) - class DetailSEGSAsRegionsService: """Detail provided SEGS through one regional MultiDiffusion pass.""" @@ -273,12 +268,10 @@ class DetailSEGSAsRegionsService: latent_for_sampling = ( self._with_noise_mask(latent, latent_regions) if noise_mask else latent ) - sampling_model = model - if noise_mask and noise_mask_feather > 0: - sampling_model = self._sampler.apply_differential_diffusion(model) + differential_diffusion = noise_mask and noise_mask_feather > 0 sampled = self._sampler.sample_regions( - model=sampling_model, + model=model, seed=seed, steps=steps, cfg=cfg, @@ -296,6 +289,7 @@ class DetailSEGSAsRegionsService: work_mask=image_union_mask, sampled_region=CropRegion(0, 0, image_width, image_height), ), + differential_diffusion=differential_diffusion, ) decoded = self._sampler.decode(vae, sampled, tiled_decode) if decoded.shape[1:3] != image_tensor.shape[1:3]: diff --git a/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py b/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py index 673ef87..1670c22 100644 --- a/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py +++ b/simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py @@ -52,6 +52,7 @@ class TiledDiffusionLatentSamplingBoundary(Protocol): latent_tile_overlap: int, latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Sample a latent using the selected tiled diffusion mode.""" @@ -84,12 +85,10 @@ class TiledDetailSamplingBoundary(Protocol): latent_tile_overlap: int, latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Sample one latent crop with the requested tiled diffusion mode.""" - def apply_differential_diffusion(self, model: Any) -> Any: - """Return a model patched for feathered denoise masks.""" - class TiledDetailResizeBoundary(Protocol): """Image resize boundary used by tiled scale-factor detailing.""" @@ -163,6 +162,7 @@ class TiledDetailSampler: latent_tile_overlap: int, latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Sample one latent crop with the selected tiled diffusion runtime.""" @@ -183,13 +183,9 @@ class TiledDetailSampler: latent_tile_overlap=latent_tile_overlap, latent_tile_batch_size=latent_tile_batch_size, preview_context=preview_context, + differential_diffusion=differential_diffusion, ) - def apply_differential_diffusion(self, model: Any) -> Any: - """Patch a model for feathered denoise masks when ComfyUI supports it.""" - - return self._detail_sampler.apply_differential_diffusion(model) - class DetailSEGSByScaleFactorTiledDiffusionService: """Detail SEGS crops with tiled diffusion latent sampling.""" @@ -251,9 +247,7 @@ class DetailSEGSByScaleFactorTiledDiffusionService: return TiledDetailerResult(image=image_tensor.clone()) working_image = image_tensor.clone() - sampling_model = model - if noise_mask and noise_mask_feather > 0: - sampling_model = self._sampler.apply_differential_diffusion(model) + differential_diffusion = noise_mask and noise_mask_feather > 0 for index, segment in enumerate(segments): plan = build_detail_scale_plan( @@ -278,7 +272,7 @@ class DetailSEGSByScaleFactorTiledDiffusionService: working_image=working_image, segment=segment, plan=plan, - model=sampling_model, + model=model, vae=vae, positive=select_conditioning(positive, index), negative=select_conditioning(negative, index), @@ -299,6 +293,7 @@ class DetailSEGSByScaleFactorTiledDiffusionService: latent_tile_height=latent_tile_height, latent_tile_overlap=latent_tile_overlap, latent_tile_batch_size=latent_tile_batch_size, + differential_diffusion=differential_diffusion, ) LOGGER.info( @@ -346,6 +341,7 @@ class DetailSEGSByScaleFactorTiledDiffusionService: latent_tile_height: int, latent_tile_overlap: int, latent_tile_batch_size: int, + differential_diffusion: bool, ) -> torch.Tensor: """Detail one segment with tiled diffusion and return the updated image.""" @@ -380,6 +376,7 @@ class DetailSEGSByScaleFactorTiledDiffusionService: latent_tile_height=latent_tile_height, latent_tile_overlap=latent_tile_overlap, latent_tile_batch_size=latent_tile_batch_size, + differential_diffusion=differential_diffusion, preview_context=DetailPreviewContext( image=working_image, work_region=segment.crop_region, diff --git a/simple_syrup/services/tiled_diffusion_sampling_service.py b/simple_syrup/services/tiled_diffusion_sampling_service.py index a467cd8..2688779 100644 --- a/simple_syrup/services/tiled_diffusion_sampling_service.py +++ b/simple_syrup/services/tiled_diffusion_sampling_service.py @@ -37,6 +37,7 @@ class TiledDiffusionSamplingService: latent_tile_overlap: int, latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Sample a latent with the selected tiled diffusion method.""" @@ -58,6 +59,7 @@ class TiledDiffusionSamplingService: latent_tile_overlap=latent_tile_overlap, latent_tile_batch_size=latent_tile_batch_size, preview_context=preview_context, + differential_diffusion=differential_diffusion, ) return mixture_of_diffusers_sampling.sample_mixture_of_diffusers( model=model, @@ -75,4 +77,5 @@ class TiledDiffusionSamplingService: latent_tile_overlap=latent_tile_overlap, latent_tile_batch_size=latent_tile_batch_size, preview_context=preview_context, + differential_diffusion=differential_diffusion, ) diff --git a/tests/test_detail_segs_as_regions_service.py b/tests/test_detail_segs_as_regions_service.py index dfb5cff..b7c8d14 100644 --- a/tests/test_detail_segs_as_regions_service.py +++ b/tests/test_detail_segs_as_regions_service.py @@ -297,8 +297,8 @@ def test_noise_mask_false_omits_latent_mask_but_composites_pixels() -> None: assert torch.all(result.image[:, 4:, :, :] == 0.0) -def test_noise_mask_feather_applies_differential_diffusion() -> None: - """Feathered denoise masks request differential diffusion patching.""" +def test_noise_mask_feather_requests_single_clone_differential_diffusion() -> None: + """Feathered denoise masks are composed inside the regional runtime clone.""" sampler = _FakeRegionalSampler() service = _service(sampler) @@ -314,8 +314,9 @@ def test_noise_mask_feather_applies_differential_diffusion() -> None: **(_settings() | {"noise_mask": True, "noise_mask_feather": 2}), ) - assert sampler.patch_count == 1 - assert sampler.sample_calls[0]["model"] == "patched model" + assert sampler.patch_count == 0 + assert sampler.sample_calls[0]["model"] == "model" + assert sampler.sample_calls[0]["differential_diffusion"] is True def test_encode_decode_and_sampler_controls_are_forwarded() -> None: @@ -479,6 +480,7 @@ class _FakeRegionalSampler: denoise: float, global_prompt_weight: float, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Record regional sample options and return the latent unchanged.""" @@ -497,6 +499,7 @@ class _FakeRegionalSampler: "denoise": denoise, "global_prompt_weight": global_prompt_weight, "preview_context": preview_context, + "differential_diffusion": differential_diffusion, } ) return latent_image diff --git a/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py b/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py index b73b63a..73be82f 100644 --- a/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py +++ b/tests/test_detail_segs_by_scale_factor_tiled_diffusion_service.py @@ -211,6 +211,29 @@ def test_tiled_noise_mask_feather_keeps_sampled_crop_geometry() -> None: assert float(noise_mask[0, 1, 1]) > float(noise_mask[0, 0, 1]) +def test_tiled_noise_mask_feather_requests_single_clone_differential_diffusion() -> ( + None +): + """Feathered denoise masks are composed inside the tiled runtime clone.""" + + sampler = _FakeTiledSampler() + service = _service(sampler) + + service.detail( + _image(), + _segs(_segment()), + "model", + "vae", + [], + [], + **(_settings() | {"noise_mask": True, "noise_mask_feather": 2}), + ) + + assert sampler.patch_count == 0 + assert sampler.sample_calls[0]["model"] == "model" + assert sampler.sample_calls[0]["differential_diffusion"] is True + + def test_tiled_detail_sampler_delegates_to_shared_sampling_service() -> None: """The detailer adapter uses the shared tiled diffusion dispatch service.""" @@ -241,6 +264,7 @@ def test_tiled_detail_sampler_delegates_to_shared_sampling_service() -> None: latent_tile_overlap=12, latent_tile_batch_size=3, preview_context=preview_context, + differential_diffusion=True, ) assert result is latent @@ -248,6 +272,7 @@ def test_tiled_detail_sampler_delegates_to_shared_sampling_service() -> None: assert call["diffusion_mode"] == "mixture_of_diffusers" assert call["latent_image"] is latent assert call["preview_context"] is preview_context + assert call["differential_diffusion"] is True class _FakeTiledSamplingService: @@ -277,6 +302,7 @@ class _FakeTiledSamplingService: latent_tile_overlap: int, latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Record tiled sampling arguments and return the latent unchanged.""" @@ -298,6 +324,7 @@ class _FakeTiledSamplingService: "latent_tile_overlap": latent_tile_overlap, "latent_tile_batch_size": latent_tile_batch_size, "preview_context": preview_context, + "differential_diffusion": differential_diffusion, } ) return latent_image @@ -356,6 +383,7 @@ class _FakeTiledSampler: latent_tile_overlap: int, latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, + differential_diffusion: bool = False, ) -> Latent: """Record tiled sample options and return the latent unchanged.""" @@ -377,6 +405,7 @@ class _FakeTiledSampler: "latent_tile_overlap": latent_tile_overlap, "latent_tile_batch_size": latent_tile_batch_size, "preview_context": preview_context, + "differential_diffusion": differential_diffusion, } ) return latent_image diff --git a/tests/test_graph_provenance.py b/tests/test_graph_provenance.py index 5906ede..ddbf023 100644 --- a/tests/test_graph_provenance.py +++ b/tests/test_graph_provenance.py @@ -47,6 +47,33 @@ def test_direct_vae_decode_resolves_samples_and_vae_links() -> None: assert result.vae_link == ("loader", 2) +def test_vae_decode_options_resolves_samples_and_vae_links() -> None: + """VAE Decode (Options) output resolves to the source latent and VAE.""" + + prompt = { + "decode": { + "class_type": "SimpleSyrup.VAEDecodeOptions", + "inputs": { + "use_tiling": True, + "samples": ["latent", 0], + "vae": ["loader", 2], + "tile_size": 512, + "overlap": 64, + "temporal_size": 64, + "temporal_overlap": 8, + }, + } + } + + result = trace_vae_decode_provenance(prompt, ["decode", 0], {}) + + assert isinstance(result, VaeDecodeProvenance) + assert result.decode_node_id == "decode" + assert result.image_output == ("decode", 0) + assert result.samples_link == ("latent", 0) + assert result.vae_link == ("loader", 2) + + def test_transparent_node_resolves_to_upstream_vae_decode() -> None: """A declared pass-through node is traversed to its source link.""" @@ -137,6 +164,25 @@ def test_vae_decode_non_image_output_breaks_provenance() -> None: assert result.reason == "VAEDecode output is not the image output" +def test_vae_decode_options_non_image_output_breaks_provenance() -> None: + """Only VAE Decode (Options) output slot 0 is trusted as image provenance.""" + + result = trace_vae_decode_provenance( + { + "decode": { + "class_type": "SimpleSyrup.VAEDecodeOptions", + "inputs": {"samples": ["latent", 0], "vae": ["loader", 2]}, + } + }, + ["decode", 1], + {}, + ) + + assert isinstance(result, BrokenProvenance) + assert result.reason == "VAEDecode output is not the image output" + assert result.class_type == "SimpleSyrup.VAEDecodeOptions" + + def test_missing_samples_link_breaks_provenance() -> None: """VAEDecode without a graph-linked samples input cannot supply provenance.""" @@ -150,6 +196,25 @@ def test_missing_samples_link_breaks_provenance() -> None: assert result.reason == "VAEDecode samples input is not a graph link" +def test_vae_decode_options_missing_samples_link_breaks_provenance() -> None: + """VAE Decode (Options) requires a graph-linked samples input.""" + + result = trace_vae_decode_provenance( + { + "decode": { + "class_type": "SimpleSyrup.VAEDecodeOptions", + "inputs": {"samples": "latent"}, + } + }, + ["decode", 0], + {}, + ) + + assert isinstance(result, BrokenProvenance) + assert result.reason == "VAEDecode samples input is not a graph link" + assert result.class_type == "SimpleSyrup.VAEDecodeOptions" + + def test_missing_node_breaks_provenance() -> None: """Missing source nodes produce broken provenance.""" diff --git a/tests/test_mixture_of_diffusers_sampling.py b/tests/test_mixture_of_diffusers_sampling.py index 0047775..19c8c60 100644 --- a/tests/test_mixture_of_diffusers_sampling.py +++ b/tests/test_mixture_of_diffusers_sampling.py @@ -83,6 +83,7 @@ class FakeModel: def __init__( self, model_options: dict[str, Any] | None = None, + parent: FakeModel | None = None, ) -> None: """Create a fake model patcher.""" @@ -90,11 +91,14 @@ class FakeModel: self.model_options = {} if model_options is None else model_options self.wrapper: Any = None self.model_sampling = object() + self.parent = parent + self.clone_count = 0 def clone(self) -> FakeModel: """Return a cloned model with copied options.""" - return FakeModel(self.model_options.copy()) + self.clone_count += 1 + return FakeModel(self.model_options.copy(), parent=self) def set_model_unet_function_wrapper(self, wrapper: object) -> None: """Capture the installed model function wrapper.""" @@ -102,6 +106,11 @@ class FakeModel: self.wrapper = wrapper self.model_options["model_function_wrapper"] = wrapper + def set_model_denoise_mask_function(self, denoise_mask_function: object) -> None: + """Capture the installed denoise-mask function.""" + + self.model_options["denoise_mask_function"] = denoise_mask_function + def get_model_object(self, name: str) -> object: """Return the requested fake model object.""" @@ -119,6 +128,31 @@ class FakeSampler: return None +def test_clone_model_with_mixture_composes_differential_on_same_clone() -> None: + """Differential diffusion is installed without cloning a temporary parent.""" + + model = FakeModel() + + wrapped_model, _plan = mod_sampling.clone_model_with_mixture_of_diffusers( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=2, + differential_diffusion=True, + ) + + assert model.clone_count == 1 + assert wrapped_model.parent is model + assert callable(wrapped_model.model_options["denoise_mask_function"]) + assert isinstance( + wrapped_model.wrapper, + mod_sampling.MixtureOfDiffusersModelWrapper, + ) + + def test_model_wrapper_tiles_input_conditioning_and_transformer_options() -> None: """The wrapper tiles latents, conditioning tensors, timesteps, and metadata.""" diff --git a/tests/test_multidiffusion_sampling.py b/tests/test_multidiffusion_sampling.py index 7562840..8f6ac87 100644 --- a/tests/test_multidiffusion_sampling.py +++ b/tests/test_multidiffusion_sampling.py @@ -91,6 +91,7 @@ class FakeModel: def __init__( self, model_options: dict[str, Any] | None = None, + parent: FakeModel | None = None, ) -> None: """Create a fake model patcher.""" @@ -98,11 +99,14 @@ class FakeModel: self.model_options = {} if model_options is None else model_options self.wrapper: Any = None self.model_sampling = object() + self.parent = parent + self.clone_count = 0 def clone(self) -> FakeModel: """Return a cloned model with copied options.""" - return FakeModel(self.model_options.copy()) + self.clone_count += 1 + return FakeModel(self.model_options.copy(), parent=self) def set_model_unet_function_wrapper(self, wrapper: object) -> None: """Capture the installed model function wrapper.""" @@ -110,6 +114,11 @@ class FakeModel: self.wrapper = wrapper self.model_options["model_function_wrapper"] = wrapper + def set_model_denoise_mask_function(self, denoise_mask_function: object) -> None: + """Capture the installed denoise-mask function.""" + + self.model_options["denoise_mask_function"] = denoise_mask_function + def get_model_object(self, name: str) -> object: """Return the requested fake model object.""" @@ -150,6 +159,31 @@ def test_clone_model_with_multidiffusion_installs_wrapper() -> None: assert plan.tile_batch_size == 2 +def test_clone_model_with_multidiffusion_composes_differential_on_same_clone() -> None: + """Differential diffusion is installed without cloning a temporary parent.""" + + model = FakeModel() + + wrapped_model, _plan = multidiffusion_sampling.clone_model_with_multidiffusion( + model, + latent_width=8, + latent_height=4, + tile_width=4, + tile_height=4, + overlap=0, + tile_batch_size=2, + differential_diffusion=True, + ) + + assert model.clone_count == 1 + assert wrapped_model.parent is model + assert callable(wrapped_model.model_options["denoise_mask_function"]) + assert isinstance( + wrapped_model.wrapper, + multidiffusion_sampling.MultiDiffusionModelWrapper, + ) + + def test_clone_model_rejects_non_callable_existing_wrapper() -> None: """Existing wrapper metadata must be callable.""" diff --git a/tests/test_node_tooltips.py b/tests/test_node_tooltips.py index 9432669..dc06039 100644 --- a/tests/test_node_tooltips.py +++ b/tests/test_node_tooltips.py @@ -20,6 +20,8 @@ from simple_syrup.nodes_v3.encode_prompt_batch_with_prompt_control import ( from simple_syrup.nodes_v3.scale_factor import ScaleFactorV3 from simple_syrup.nodes_v3.simple_load_checkpoint import SimpleLoadCheckpointV3 from simple_syrup.nodes_v3.tile_and_tag_segs import TileAndTagSEGSV3 +from simple_syrup.nodes_v3.vae_decode_options import VAEDecodeOptionsV3 +from simple_syrup.nodes_v3.vae_encode_options import VAEEncodeOptionsV3 from simple_syrup.nodes_v3.wd14_tagger_loader import WD14TaggerLoaderV3 @@ -116,6 +118,8 @@ def test_legacy_named_outputs_provide_tooltips() -> None: SimpleLoadCheckpointV3, ScaleFactorV3, TileAndTagSEGSV3, + VAEDecodeOptionsV3, + VAEEncodeOptionsV3, WD14TaggerLoaderV3, EncodePromptBatchWithPromptControl, ], diff --git a/tests/test_regional_multidiffusion_sampling.py b/tests/test_regional_multidiffusion_sampling.py index 766bdf4..486180d 100644 --- a/tests/test_regional_multidiffusion_sampling.py +++ b/tests/test_regional_multidiffusion_sampling.py @@ -94,6 +94,7 @@ class FakeModel: def __init__( self, model_options: dict[str, Any] | None = None, + parent: FakeModel | None = None, ) -> None: """Create a fake model patcher.""" @@ -101,11 +102,14 @@ class FakeModel: self.model_options = {} if model_options is None else model_options self.calc_wrapper: Any = None self.model_sampling = object() + self.parent = parent + self.clone_count = 0 def clone(self) -> FakeModel: """Return a cloned model with copied options.""" - return FakeModel(self.model_options.copy()) + self.clone_count += 1 + return FakeModel(self.model_options.copy(), parent=self) def set_model_sampler_calc_cond_batch_function(self, wrapper: object) -> None: """Capture the installed calc-cond-batch wrapper.""" @@ -113,6 +117,11 @@ class FakeModel: self.calc_wrapper = wrapper self.model_options["sampler_calc_cond_batch_function"] = wrapper + def set_model_denoise_mask_function(self, denoise_mask_function: object) -> None: + """Capture the installed denoise-mask function.""" + + self.model_options["denoise_mask_function"] = denoise_mask_function + def get_model_object(self, name: str) -> object: """Return the requested fake model object.""" @@ -153,6 +162,31 @@ def test_clone_model_installs_regional_calc_cond_batch_wrapper() -> None: assert summary.region_count == 1 +def test_clone_model_composes_differential_on_same_clone() -> None: + """Differential diffusion is installed without cloning a temporary parent.""" + + model = FakeModel() + + wrapped_model, _summary = ( + regional_multidiffusion_sampling.clone_model_with_regional_multidiffusion( + model, + latent_width=8, + latent_height=4, + latent_ndim=4, + regions=(_region(0, 0, 4, 4, "positive", latent_width=8),), + differential_diffusion=True, + ) + ) + + assert model.clone_count == 1 + assert wrapped_model.parent is model + assert callable(wrapped_model.model_options["denoise_mask_function"]) + assert isinstance( + wrapped_model.calc_wrapper, + regional_multidiffusion_sampling.RegionalMultiDiffusionCalcCondBatch, + ) + + def test_clone_model_rejects_non_callable_existing_calc_wrapper() -> None: """Existing calc-cond-batch metadata must be callable.""" diff --git a/tests/test_registration.py b/tests/test_registration.py index 501e47e..d550fb5 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -476,6 +476,29 @@ def test_provenance_latent_nodes_are_registered() -> None: assert "UpscaleLatentFromImage" in nodes_package.__all__ +def test_vae_options_nodes_are_registered() -> None: + """VAE options nodes map to their classes and display names.""" + + package = importlib.import_module("SimpleSyrup") + nodes_package = importlib.import_module("SimpleSyrup.simple_syrup.nodes") + + encode = package.NODE_CLASS_MAPPINGS["SimpleSyrup.VAEEncodeOptions"] + decode = package.NODE_CLASS_MAPPINGS["SimpleSyrup.VAEDecodeOptions"] + + assert encode.__name__ == "VAEEncodeOptions" + assert decode.__name__ == "VAEDecodeOptions" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.VAEEncodeOptions"] + == "VAE Encode (Options)" + ) + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.VAEDecodeOptions"] + == "VAE Decode (Options)" + ) + assert "VAEEncodeOptions" in nodes_package.__all__ + assert "VAEDecodeOptions" in nodes_package.__all__ + + def test_registration_import_does_not_require_torchlanc() -> None: """Importing registration does not eagerly import TorchLanc.""" @@ -504,6 +527,8 @@ def test_v3_entrypoint_registers_tile_and_prompt_control_batch_nodes( "TileAndTagSEGSV3", "SimpleLoadCheckpointV3", "ScaleFactorV3", + "VAEDecodeOptionsV3", + "VAEEncodeOptionsV3", "EncodePromptBatchWithPromptControl", ] assert "prompt_control.nodes_lazy" not in sys.modules @@ -527,5 +552,7 @@ def test_v3_entrypoint_keeps_tile_node_when_prompt_control_unavailable( "TileAndTagSEGSV3", "SimpleLoadCheckpointV3", "ScaleFactorV3", + "VAEDecodeOptionsV3", + "VAEEncodeOptionsV3", ] assert "prompt_control.nodes_lazy" not in sys.modules diff --git a/tests/test_tiled_diffusion_sampling_service.py b/tests/test_tiled_diffusion_sampling_service.py index d492b7d..fa616d1 100644 --- a/tests/test_tiled_diffusion_sampling_service.py +++ b/tests/test_tiled_diffusion_sampling_service.py @@ -129,6 +129,35 @@ def test_service_forwards_sampling_arguments_unchanged( } +def test_service_forwards_differential_diffusion_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The dispatcher preserves differential-denoise-mask composition requests.""" + + calls: dict[str, Any] = {} + + def fake_multidiffusion(**kwargs: Any) -> dict[str, Any]: + """Record forwarded arguments.""" + + calls.update(kwargs) + return {"samples": torch.ones((1, 4, 4, 4))} + + monkeypatch.setattr( + "simple_syrup.services.tiled_diffusion_sampling_service." + "multidiffusion_sampling.sample_multidiffusion", + fake_multidiffusion, + ) + + TiledDiffusionSamplingService().sample( + **( + _sample_kwargs(diffusion_mode="multidiffusion") + | {"differential_diffusion": True} + ) + ) + + assert calls["differential_diffusion"] is True + + def test_invalid_mode_fails_before_runtime_call( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -181,4 +210,5 @@ def _sample_kwargs( "latent_tile_overlap": 24, "latent_tile_batch_size": 3, "preview_context": preview_context, + "differential_diffusion": False, } diff --git a/tests/test_vae_decode_options_v3_node.py b/tests/test_vae_decode_options_v3_node.py new file mode 100644 index 0000000..13391f5 --- /dev/null +++ b/tests/test_vae_decode_options_v3_node.py @@ -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 + +"""Tests for the VAE Decode (Options) Comfy v3 wrapper.""" + +from __future__ import annotations + +from importlib import import_module + +from simple_syrup.nodes import tooltips +from simple_syrup.nodes.vae_options import ( + VAE_DECODE_TILE_SIZE_STEP, + VAE_OVERLAP_DEFAULT, + VAE_TEMPORAL_OVERLAP_DEFAULT, + VAE_TEMPORAL_SIZE_DEFAULT, + VAE_TILE_SIZE_DEFAULT, + VAEDecodeOptions, +) +from simple_syrup.nodes_v3.vae_decode_options import VAEDecodeOptionsV3 + + +def test_vae_decode_options_v3_schema() -> None: + """VAE Decode (Options) v3 schema mirrors the legacy node contract.""" + + schema = VAEDecodeOptionsV3.define_schema() + + assert schema.node_id == "SimpleSyrup.VAEDecodeOptions" + assert schema.display_name == "VAE Decode (Options)" + assert schema.category == "SimpleSyrup/Latent" + assert schema.description == VAEDecodeOptions.DESCRIPTION + assert schema.enable_expand is True + assert [input_item.id for input_item in schema.inputs] == [ + "use_tiling", + "samples", + "vae", + "tile_size", + "overlap", + "temporal_size", + "temporal_overlap", + ] + assert schema.inputs[0].io_type == "BOOLEAN" + assert schema.inputs[0].default is False + assert schema.inputs[0].tooltip == tooltips.VAE_OPTIONS_USE_TILING + assert schema.inputs[1].io_type == "LATENT" + assert schema.inputs[1].rawLink is True + assert schema.inputs[2].io_type == "VAE" + assert schema.inputs[2].rawLink is True + assert schema.inputs[3].default == VAE_TILE_SIZE_DEFAULT + assert schema.inputs[3].step == VAE_DECODE_TILE_SIZE_STEP + assert schema.inputs[4].default == VAE_OVERLAP_DEFAULT + assert schema.inputs[5].default == VAE_TEMPORAL_SIZE_DEFAULT + assert schema.inputs[6].default == VAE_TEMPORAL_OVERLAP_DEFAULT + assert [output.id for output in schema.outputs] == ["image"] + assert schema.outputs[0].io_type == "IMAGE" + assert schema.outputs[0].tooltip == tooltips.VAE_OPTIONS_IMAGE_OUTPUT + + +def test_vae_decode_options_v3_execute_delegates_to_legacy_node() -> None: + """VAE Decode (Options) v3 execution returns legacy expansion behavior.""" + + graph_utils = import_module("comfy_execution.graph_utils") + graph_utils.GraphBuilder.set_default_prefix("V3_DECODE", 0, 0) + + result = VAEDecodeOptionsV3.execute( + True, + ["latent", 0], + ["loader", 2], + 1024, + 128, + 96, + 16, + ) + node = next(iter(result["expand"].values())) + + assert node["class_type"] == "VAEDecodeTiled" + assert node["inputs"]["tile_size"] == 1024 + assert result["result"][0][1] == 0 diff --git a/tests/test_vae_encode_options_v3_node.py b/tests/test_vae_encode_options_v3_node.py new file mode 100644 index 0000000..8c3dcb2 --- /dev/null +++ b/tests/test_vae_encode_options_v3_node.py @@ -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 + +"""Tests for the VAE Encode (Options) Comfy v3 wrapper.""" + +from __future__ import annotations + +from importlib import import_module + +from simple_syrup.nodes import tooltips +from simple_syrup.nodes.vae_options import ( + VAE_ENCODE_TILE_SIZE_STEP, + VAE_OVERLAP_DEFAULT, + VAE_TEMPORAL_OVERLAP_DEFAULT, + VAE_TEMPORAL_SIZE_DEFAULT, + VAE_TILE_SIZE_DEFAULT, + VAEEncodeOptions, +) +from simple_syrup.nodes_v3.vae_encode_options import VAEEncodeOptionsV3 + + +def test_vae_encode_options_v3_schema() -> None: + """VAE Encode (Options) v3 schema mirrors the legacy node contract.""" + + schema = VAEEncodeOptionsV3.define_schema() + + assert schema.node_id == "SimpleSyrup.VAEEncodeOptions" + assert schema.display_name == "VAE Encode (Options)" + assert schema.category == "SimpleSyrup/Latent" + assert schema.description == VAEEncodeOptions.DESCRIPTION + assert schema.enable_expand is True + assert [input_item.id for input_item in schema.inputs] == [ + "use_tiling", + "pixels", + "vae", + "tile_size", + "overlap", + "temporal_size", + "temporal_overlap", + ] + assert schema.inputs[0].io_type == "BOOLEAN" + assert schema.inputs[0].default is False + assert schema.inputs[0].tooltip == tooltips.VAE_OPTIONS_USE_TILING + assert schema.inputs[1].io_type == "IMAGE" + assert schema.inputs[1].rawLink is True + assert schema.inputs[2].io_type == "VAE" + assert schema.inputs[2].rawLink is True + assert schema.inputs[3].default == VAE_TILE_SIZE_DEFAULT + assert schema.inputs[3].step == VAE_ENCODE_TILE_SIZE_STEP + assert schema.inputs[4].default == VAE_OVERLAP_DEFAULT + assert schema.inputs[5].default == VAE_TEMPORAL_SIZE_DEFAULT + assert schema.inputs[6].default == VAE_TEMPORAL_OVERLAP_DEFAULT + assert [output.id for output in schema.outputs] == ["latent"] + assert schema.outputs[0].io_type == "LATENT" + assert schema.outputs[0].tooltip == tooltips.VAE_OPTIONS_LATENT_OUTPUT + + +def test_vae_encode_options_v3_execute_delegates_to_legacy_node() -> None: + """VAE Encode (Options) v3 execution returns legacy expansion behavior.""" + + graph_utils = import_module("comfy_execution.graph_utils") + graph_utils.GraphBuilder.set_default_prefix("V3_ENCODE", 0, 0) + + result = VAEEncodeOptionsV3.execute( + True, + ["image", 0], + ["loader", 2], + 768, + 96, + 48, + 12, + ) + node = next(iter(result["expand"].values())) + + assert node["class_type"] == "VAEEncodeTiled" + assert node["inputs"]["tile_size"] == 768 + assert result["result"][0][1] == 0 diff --git a/tests/test_vae_options_node.py b/tests/test_vae_options_node.py new file mode 100644 index 0000000..25c4ce0 --- /dev/null +++ b/tests/test_vae_options_node.py @@ -0,0 +1,226 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for VAE Encode/Decode Options nodes.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + +from simple_syrup.nodes.vae_options import ( + VAE_DECODE_TILE_SIZE_STEP, + VAE_ENCODE_TILE_SIZE_STEP, + VAE_OVERLAP_DEFAULT, + VAE_OVERLAP_MAX, + VAE_OVERLAP_MIN, + VAE_OVERLAP_STEP, + VAE_TEMPORAL_OVERLAP_DEFAULT, + VAE_TEMPORAL_OVERLAP_MAX, + VAE_TEMPORAL_OVERLAP_MIN, + VAE_TEMPORAL_OVERLAP_STEP, + VAE_TEMPORAL_SIZE_DEFAULT, + VAE_TEMPORAL_SIZE_MAX, + VAE_TEMPORAL_SIZE_MIN, + VAE_TEMPORAL_SIZE_STEP, + VAE_TILE_SIZE_DEFAULT, + VAE_TILE_SIZE_MAX, + VAE_TILE_SIZE_MIN, + VAEDecodeOptions, + VAEEncodeOptions, +) + + +def test_vae_encode_options_declares_inputs() -> None: + """VAE Encode (Options) exposes normal/tiled controls.""" + + inputs = VAEEncodeOptions.INPUT_TYPES()["required"] + + assert VAEEncodeOptions.RETURN_TYPES == ("LATENT",) + assert VAEEncodeOptions.RETURN_NAMES == ("latent",) + assert list(inputs) == [ + "use_tiling", + "pixels", + "vae", + "tile_size", + "overlap", + "temporal_size", + "temporal_overlap", + ] + assert inputs["use_tiling"][0] == "BOOLEAN" + assert inputs["use_tiling"][1]["default"] is False + assert inputs["pixels"][0] == "IMAGE" + assert inputs["pixels"][1]["rawLink"] is True + assert inputs["vae"][0] == "VAE" + assert inputs["vae"][1]["rawLink"] is True + _assert_tile_controls(inputs, tile_size_step=VAE_ENCODE_TILE_SIZE_STEP) + + +def test_vae_decode_options_declares_inputs() -> None: + """VAE Decode (Options) exposes normal/tiled controls.""" + + inputs = VAEDecodeOptions.INPUT_TYPES()["required"] + + assert VAEDecodeOptions.RETURN_TYPES == ("IMAGE",) + assert VAEDecodeOptions.RETURN_NAMES == ("image",) + assert list(inputs) == [ + "use_tiling", + "samples", + "vae", + "tile_size", + "overlap", + "temporal_size", + "temporal_overlap", + ] + assert inputs["use_tiling"][0] == "BOOLEAN" + assert inputs["use_tiling"][1]["default"] is False + assert inputs["samples"][0] == "LATENT" + assert inputs["samples"][1]["rawLink"] is True + assert inputs["vae"][0] == "VAE" + assert inputs["vae"][1]["rawLink"] is True + _assert_tile_controls(inputs, tile_size_step=VAE_DECODE_TILE_SIZE_STEP) + + +def test_vae_encode_options_expands_to_native_encode() -> None: + """Normal encode mode expands to ComfyUI's native VAEEncode.""" + + _set_graph_prefix("ENCODE_NORMAL") + result = VAEEncodeOptions().encode( + use_tiling=False, + pixels=("image", 0), + vae=("loader", 2), + tile_size=512, + overlap=64, + temporal_size=64, + temporal_overlap=8, + ) + + node = _single_node(result["expand"]) + assert node["class_type"] == "VAEEncode" + assert node["inputs"] == {"pixels": ["image", 0], "vae": ["loader", 2]} + assert result["result"][0][0] in result["expand"] + assert result["result"][0][1] == 0 + + +def test_vae_encode_options_expands_to_native_tiled_encode() -> None: + """Tiled encode mode expands to ComfyUI's native VAEEncodeTiled.""" + + _set_graph_prefix("ENCODE_TILED") + result = VAEEncodeOptions().encode( + use_tiling=True, + pixels=["image", 0], + vae=["loader", 2], + tile_size=768, + overlap=96, + temporal_size=48, + temporal_overlap=12, + ) + + node = _single_node(result["expand"]) + assert node["class_type"] == "VAEEncodeTiled" + assert node["inputs"] == { + "pixels": ["image", 0], + "vae": ["loader", 2], + "tile_size": 768, + "overlap": 96, + "temporal_size": 48, + "temporal_overlap": 12, + } + + +def test_vae_decode_options_expands_to_native_decode() -> None: + """Normal decode mode expands to ComfyUI's native VAEDecode.""" + + _set_graph_prefix("DECODE_NORMAL") + result = VAEDecodeOptions().decode( + use_tiling=False, + samples=("latent", 0), + vae=("loader", 2), + tile_size=512, + overlap=64, + temporal_size=64, + temporal_overlap=8, + ) + + node = _single_node(result["expand"]) + assert node["class_type"] == "VAEDecode" + assert node["inputs"] == {"samples": ["latent", 0], "vae": ["loader", 2]} + assert result["result"][0][0] in result["expand"] + assert result["result"][0][1] == 0 + + +def test_vae_decode_options_expands_to_native_tiled_decode() -> None: + """Tiled decode mode expands to ComfyUI's native VAEDecodeTiled.""" + + _set_graph_prefix("DECODE_TILED") + result = VAEDecodeOptions().decode( + use_tiling=True, + samples=["latent", 0], + vae=["loader", 2], + tile_size=1024, + overlap=128, + temporal_size=96, + temporal_overlap=16, + ) + + node = _single_node(result["expand"]) + assert node["class_type"] == "VAEDecodeTiled" + assert node["inputs"] == { + "samples": ["latent", 0], + "vae": ["loader", 2], + "tile_size": 1024, + "overlap": 128, + "temporal_size": 96, + "temporal_overlap": 16, + } + + +def _assert_tile_controls( + inputs: dict[str, tuple[Any, ...]], + *, + tile_size_step: int, +) -> None: + """Assert shared VAE tile control metadata.""" + + tile_size = inputs["tile_size"][1] + assert tile_size["default"] == VAE_TILE_SIZE_DEFAULT + assert tile_size["min"] == VAE_TILE_SIZE_MIN + assert tile_size["max"] == VAE_TILE_SIZE_MAX + assert tile_size["step"] == tile_size_step + assert tile_size["advanced"] is True + + overlap = inputs["overlap"][1] + assert overlap["default"] == VAE_OVERLAP_DEFAULT + assert overlap["min"] == VAE_OVERLAP_MIN + assert overlap["max"] == VAE_OVERLAP_MAX + assert overlap["step"] == VAE_OVERLAP_STEP + assert overlap["advanced"] is True + + temporal_size = inputs["temporal_size"][1] + assert temporal_size["default"] == VAE_TEMPORAL_SIZE_DEFAULT + assert temporal_size["min"] == VAE_TEMPORAL_SIZE_MIN + assert temporal_size["max"] == VAE_TEMPORAL_SIZE_MAX + assert temporal_size["step"] == VAE_TEMPORAL_SIZE_STEP + assert temporal_size["advanced"] is True + + temporal_overlap = inputs["temporal_overlap"][1] + assert temporal_overlap["default"] == VAE_TEMPORAL_OVERLAP_DEFAULT + assert temporal_overlap["min"] == VAE_TEMPORAL_OVERLAP_MIN + assert temporal_overlap["max"] == VAE_TEMPORAL_OVERLAP_MAX + assert temporal_overlap["step"] == VAE_TEMPORAL_OVERLAP_STEP + assert temporal_overlap["advanced"] is True + + +def _single_node(graph: dict[str, dict[str, Any]]) -> dict[str, Any]: + """Return the only node from a dynamic expansion graph.""" + + assert len(graph) == 1 + return next(iter(graph.values())) + + +def _set_graph_prefix(prefix: str) -> None: + """Set a deterministic Comfy graph-builder prefix for assertions.""" + + graph_utils = import_module("comfy_execution.graph_utils") + graph_utils.GraphBuilder.set_default_prefix(prefix, 0, 0) From 17c118cba29a495ae66ebc5b2640c9de772eced5 Mon Sep 17 00:00:00 2001 From: Daisy Date: Mon, 25 May 2026 17:44:38 +0000 Subject: [PATCH 08/40] 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)) --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- pyproject.toml | 2 +- simple_syrup/__init__.py | 2 +- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 958e718..75c5c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [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)) + # [1.1.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.0.0...v1.1.0) (2026-05-23) diff --git a/package-lock.json b/package-lock.json index 4cf67d0..51b5e29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-syrup-comfyui", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-syrup-comfyui", - "version": "1.1.0", + "version": "1.2.0", "license": "AGPL-3.0-or-later", "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/package.json b/package.json index 77d2dfc..ab32dfc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-syrup-comfyui", - "version": "1.1.0", + "version": "1.2.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index 4e599a9..b81e2b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.2.0" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] requires-python = ">=3.11" diff --git a/simple_syrup/__init__.py b/simple_syrup/__init__.py index 7c2aa87..961e224 100644 --- a/simple_syrup/__init__.py +++ b/simple_syrup/__init__.py @@ -6,6 +6,6 @@ from __future__ import annotations -__version__ = "1.1.0" +__version__ = "1.2.0" __all__: list[str] = ["__version__"] From 375d603a8e54a1454de6c56fd13986b3c15458b6 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Tue, 26 May 2026 17:45:21 -0400 Subject: [PATCH 09/40] 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. --- simple_syrup/domain/prompt_control_prompt.py | 76 +++++ simple_syrup/nodes/__init__.py | 17 ++ simple_syrup/nodes/ksampler_extras.py | 114 +++++++- .../nodes/ksampler_tiled_diffusion.py | 4 +- simple_syrup/nodes/prompt_encode_style.py | 4 +- .../prompt_encode_style_and_normalization.py | 4 +- ..._and_encode_prompts_with_prompt_control.py | 118 ++++++++ simple_syrup/nodes_v3/__init__.py | 4 + ..._and_encode_prompts_with_prompt_control.py | 137 +++++++++ .../prompt_control_schedule_encode_graph.py | 195 +++++++++++++ .../tiled_diffusion_sampling_service.py | 124 ++++++++ tests/test_ksampler_extras_node.py | 107 +++++++ tests/test_ksampler_tiled_diffusion_node.py | 2 + tests/test_node_tooltips.py | 4 + tests/test_prompt_control_prompt.py | 83 ++++++ ...st_prompt_control_schedule_encode_graph.py | 224 ++++++++++++++ tests/test_prompt_encode_style_nodes.py | 20 +- tests/test_registration.py | 25 ++ ...encode_prompts_with_prompt_control_node.py | 273 ++++++++++++++++++ .../test_tiled_diffusion_sampling_service.py | 58 ++++ 20 files changed, 1561 insertions(+), 32 deletions(-) create mode 100644 simple_syrup/domain/prompt_control_prompt.py create mode 100644 simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py create mode 100644 simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py create mode 100644 simple_syrup/runtime/prompt_control_schedule_encode_graph.py create mode 100644 tests/test_prompt_control_prompt.py create mode 100644 tests/test_prompt_control_schedule_encode_graph.py create mode 100644 tests/test_schedule_and_encode_prompts_with_prompt_control_node.py diff --git a/simple_syrup/domain/prompt_control_prompt.py b/simple_syrup/domain/prompt_control_prompt.py new file mode 100644 index 0000000..a1bbe4a --- /dev/null +++ b/simple_syrup/domain/prompt_control_prompt.py @@ -0,0 +1,76 @@ +# 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 .conditioning_batch 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 and all scheduling tags for one prompt side.""" + + chunks: tuple[PreparedPromptChunk, ...] + 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 cleaned chunks and aggregate LoRA tags.""" + + chunks = tuple( + PreparedPromptChunk( + text=extract_prompt_text(chunk), + lora_tags=extract_lora_tags(chunk), + ) + for chunk in split_prompt_batch(text, separator) + ) + lora_tags = "\n".join(chunk.lora_tags for chunk in chunks if chunk.lora_tags) + return PreparedPromptSide(chunks=chunks, lora_tags=lora_tags) + + +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) diff --git a/simple_syrup/nodes/__init__.py b/simple_syrup/nodes/__init__.py index c06593d..7934259 100644 --- a/simple_syrup/nodes/__init__.py +++ b/simple_syrup/nodes/__init__.py @@ -6,6 +6,7 @@ from __future__ import annotations +from ..runtime.prompt_control_availability import prompt_control_is_available from .conditioning_batch_pack import ConditioningBatchAppend, ConditioningBatchStart from .detail_segs_as_regions import DetailSEGSAsRegions from .detail_segs_by_scale_factor import DetailSEGSByScaleFactor @@ -36,6 +37,8 @@ from .vae_options import VAEDecodeOptions, VAEEncodeOptions from .vitmatte_model_loader import ViTMatteModelLoader from .wd14_tagger_loader import WD14TaggerLoader +_PROMPT_CONTROL_EXPORTS: list[str] = [] + NODE_CLASS_MAPPINGS = { "SimpleSyrup.ConditioningBatchAppend": ConditioningBatchAppend, "SimpleSyrup.ConditioningBatchStart": ConditioningBatchStart, @@ -108,6 +111,19 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SimpleSyrup.WD14TaggerLoader": "Load WD14 Tagger", } +if prompt_control_is_available(): + from .schedule_and_encode_prompts_with_prompt_control import ( + ScheduleAndEncodePromptsWithPromptControl, + ) + + NODE_CLASS_MAPPINGS["SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl"] = ( + ScheduleAndEncodePromptsWithPromptControl + ) + NODE_DISPLAY_NAME_MAPPINGS[ + "SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl" + ] = "Schedule & Encode Prompts" + _PROMPT_CONTROL_EXPORTS.append("ScheduleAndEncodePromptsWithPromptControl") + __all__ = [ "ConditioningBatchAppend", "ConditioningBatchStart", @@ -130,6 +146,7 @@ __all__ = [ "PromptSEGSWithSAM", "ResizeImageToTarget", "SAMModelLoader", + *_PROMPT_CONTROL_EXPORTS, "ScaleFactor", "Seed", "SimpleLoadAnima", diff --git a/simple_syrup/nodes/ksampler_extras.py b/simple_syrup/nodes/ksampler_extras.py index 8ca1699..b44b00e 100644 --- a/simple_syrup/nodes/ksampler_extras.py +++ b/simple_syrup/nodes/ksampler_extras.py @@ -9,6 +9,9 @@ from __future__ import annotations from importlib import import_module from typing import Any +import torch + +from ..domain.conditioning_batch import ConditioningBatch, select_conditioning from ..runtime import sampling_samplers, sampling_schedulers from . import tooltips @@ -74,11 +77,11 @@ class KSamplerExtras: {"tooltip": tooltips.SCHEDULER}, ), "positive": ( - "CONDITIONING", + "CONDITIONING,CONDITIONING_BATCH", {"tooltip": tooltips.POSITIVE_CONDITIONING}, ), "negative": ( - "CONDITIONING", + "CONDITIONING,CONDITIONING_BATCH", {"tooltip": tooltips.NEGATIVE_CONDITIONING}, ), "latent_image": ("LATENT", {"tooltip": tooltips.LATENT_IMAGE}), @@ -138,20 +141,37 @@ class KSamplerExtras: callback = latent_preview.prepare_callback(model, steps) disable_pbar = not comfy_utils.PROGRESS_BAR_ENABLED - samples = comfy_sample.sample_custom( - model, - noise, - cfg, - sampler, - sigmas, - positive, - negative, - latent_samples, - noise_mask=noise_mask, - callback=callback, - disable_pbar=disable_pbar, - seed=seed, - ) + if _uses_conditioning_batch(positive, negative): + samples = _sample_conditioning_batch( + comfy_sample=comfy_sample, + model=model, + noise=noise, + cfg=cfg, + sampler=sampler, + sigmas=sigmas, + positive=positive, + negative=negative, + latent_samples=latent_samples, + noise_mask=noise_mask, + callback=callback, + disable_pbar=disable_pbar, + seed=seed, + ) + else: + samples = comfy_sample.sample_custom( + model, + noise, + cfg, + sampler, + sigmas, + positive, + negative, + latent_samples, + noise_mask=noise_mask, + callback=callback, + disable_pbar=disable_pbar, + seed=seed, + ) output = latent_image.copy() output.pop("downscale_ratio_spacial", None) @@ -167,6 +187,68 @@ def _comfy_sample() -> Any: return comfy.sample +def _uses_conditioning_batch(positive: Any, negative: Any) -> bool: + """Return whether either conditioning input needs per-item selection.""" + + return isinstance(positive, ConditioningBatch) or isinstance( + negative, + ConditioningBatch, + ) + + +def _sample_conditioning_batch( + *, + comfy_sample: Any, + model: Any, + noise: torch.Tensor, + cfg: float, + sampler: Any, + sigmas: torch.Tensor, + positive: Any, + negative: Any, + latent_samples: torch.Tensor, + noise_mask: Any, + callback: Any, + disable_pbar: bool, + seed: int, +) -> torch.Tensor: + """Sample each latent batch item with its selected conditioning.""" + + sampled: list[torch.Tensor] = [] + for index in range(int(latent_samples.shape[0])): + sampled.append( + comfy_sample.sample_custom( + model, + noise[index : index + 1], + cfg, + sampler, + sigmas, + select_conditioning(positive, index), + select_conditioning(negative, index), + latent_samples[index : index + 1], + noise_mask=_slice_noise_mask(noise_mask, index, latent_samples), + callback=callback, + disable_pbar=disable_pbar, + seed=seed, + ) + ) + return torch.cat(sampled, dim=0) + + +def _slice_noise_mask( + noise_mask: Any, + index: int, + latent_samples: torch.Tensor, +) -> Any: + """Return the noise mask slice matching one latent batch item.""" + + if isinstance(noise_mask, torch.Tensor) and noise_mask.shape[0] == int( + latent_samples.shape[0], + ): + return noise_mask[index : index + 1] + return noise_mask + + def _comfy_utils() -> Any: """Import ComfyUI utility state lazily.""" diff --git a/simple_syrup/nodes/ksampler_tiled_diffusion.py b/simple_syrup/nodes/ksampler_tiled_diffusion.py index 0ba0cd7..ada4761 100644 --- a/simple_syrup/nodes/ksampler_tiled_diffusion.py +++ b/simple_syrup/nodes/ksampler_tiled_diffusion.py @@ -84,11 +84,11 @@ class KSamplerTiledDiffusion: {"tooltip": tooltips.SCHEDULER}, ), "positive": ( - "CONDITIONING", + "CONDITIONING,CONDITIONING_BATCH", {"tooltip": tooltips.POSITIVE_CONDITIONING}, ), "negative": ( - "CONDITIONING", + "CONDITIONING,CONDITIONING_BATCH", {"tooltip": tooltips.NEGATIVE_CONDITIONING}, ), "latent_image": ("LATENT", {"tooltip": tooltips.LATENT_IMAGE}), diff --git a/simple_syrup/nodes/prompt_encode_style.py b/simple_syrup/nodes/prompt_encode_style.py index cdf403e..34d65e4 100644 --- a/simple_syrup/nodes/prompt_encode_style.py +++ b/simple_syrup/nodes/prompt_encode_style.py @@ -15,8 +15,8 @@ class PromptEncodeStyle: """Build Prompt Control STYLE tags from encode-style selections.""" RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("style_tag",) - OUTPUT_TOOLTIPS = ("Prompt Control STYLE tag text for prompt encoding workflows.",) + RETURN_NAMES = ("encode_style",) + OUTPUT_TOOLTIPS = ("Prompt Control encode style text for prompt workflows.",) FUNCTION = "build" CATEGORY = "SimpleSyrup/Prompt" DESCRIPTION = "Builds a Prompt Control STYLE tag from an encode-style selection." diff --git a/simple_syrup/nodes/prompt_encode_style_and_normalization.py b/simple_syrup/nodes/prompt_encode_style_and_normalization.py index 0664006..b2e95a9 100644 --- a/simple_syrup/nodes/prompt_encode_style_and_normalization.py +++ b/simple_syrup/nodes/prompt_encode_style_and_normalization.py @@ -19,9 +19,9 @@ class PromptEncodeStyleAndNormalization: """Build STYLE tags from encode-style and normalization selections.""" RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("style_tag",) + RETURN_NAMES = ("encode_style",) OUTPUT_TOOLTIPS = ( - "Prompt Control STYLE tag text with the selected normalization behavior.", + "Prompt Control encode style text with the selected normalization behavior.", ) FUNCTION = "build" CATEGORY = "SimpleSyrup/Prompt" diff --git a/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py b/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py new file mode 100644 index 0000000..22cd362 --- /dev/null +++ b/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py @@ -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 + +"""Legacy ComfyUI node for Prompt-Control prompt scheduling and encoding.""" + +from __future__ import annotations + +from typing import Any + +from ..runtime.prompt_control_schedule_encode_graph import ( + PromptControlScheduleEncodeGraphBuilder, +) + + +class ScheduleAndEncodePromptsWithPromptControl: + """Schedule Prompt-Control LoRAs and encode prompts with optional batches.""" + + RETURN_TYPES = ( + "MODEL", + "CONDITIONING,CONDITIONING_BATCH", + "CONDITIONING,CONDITIONING_BATCH", + ) + RETURN_NAMES = ("model", "positive", "negative") + OUTPUT_TOOLTIPS = ( + "Model after LoRA tags from positive and negative prompts are scheduled.", + "Positive conditioning or SimpleSyrup conditioning batch.", + "Negative conditioning or SimpleSyrup conditioning batch.", + ) + FUNCTION = "execute" + CATEGORY = "SimpleSyrup/Conditioning" + DESCRIPTION = ( + "Schedules Prompt-Control LoRAs and encodes prompts, using [SEP] to " + "create SimpleSyrup conditioning batches." + ) + SEARCH_ALIASES = ["prompt control", "schedule prompts", "encode prompts"] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare Prompt-Control schedule and encode inputs.""" + + return { + "required": { + "model": ( + "MODEL", + { + "rawLink": True, + "tooltip": ( + "Model that receives LoRA changes found in " + "Prompt-Control prompt tags." + ), + }, + ), + "clip": ( + "CLIP", + { + "rawLink": True, + "tooltip": ( + "CLIP connection used for scheduled hooks and " + "cleaned prompt encoding." + ), + }, + ), + "positive_prompt": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": ( + "Positive Prompt-Control text. [SEP] creates a " + "conditioning batch for SimpleSyrup batch-aware nodes." + ), + }, + ), + "negative_prompt": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": ( + "Negative Prompt-Control text. [SEP] creates a " + "conditioning batch for SimpleSyrup batch-aware nodes." + ), + }, + ), + }, + "optional": { + "encode_style": ( + "STRING", + { + "default": "", + "forceInput": True, + "tooltip": ( + "Encode style text from Prompt Encode Style or " + "Prompt Encode Style & Normalization." + ), + }, + ), + }, + } + + def execute( + self, + model: Any, + clip: Any, + positive_prompt: str, + negative_prompt: str, + encode_style: str = "", + ) -> Any: + """Build lazy Prompt-Control graph expansion for prompts.""" + + return PromptControlScheduleEncodeGraphBuilder().build( + model=model, + clip=clip, + positive_prompt=positive_prompt, + negative_prompt=negative_prompt, + encode_style=encode_style, + ) diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index fd6446e..efafa0f 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -32,6 +32,9 @@ def get_nodes() -> list[type[object]]: from .encode_prompt_batch_with_prompt_control import ( EncodePromptBatchWithPromptControl, ) + from .schedule_and_encode_prompts_with_prompt_control import ( + ScheduleAndEncodePromptsWithPromptControl, + ) return [ WD14TaggerLoaderV3, @@ -41,6 +44,7 @@ def get_nodes() -> list[type[object]]: VAEDecodeOptionsV3, VAEEncodeOptionsV3, EncodePromptBatchWithPromptControl, + ScheduleAndEncodePromptsWithPromptControl, ] diff --git a/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py b/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py new file mode 100644 index 0000000..5b33d56 --- /dev/null +++ b/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py @@ -0,0 +1,137 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node for Prompt-Control prompt scheduling and encoding.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..runtime.prompt_control_schedule_encode_graph import ( + PromptControlScheduleEncodeGraphBuilder, +) + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Return v1-compatible input metadata.""" + + raise NotImplementedError + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io +MixedConditioningIO: Any = ( + None if TYPE_CHECKING else _comfy_io.Custom("CONDITIONING,CONDITIONING_BATCH") +) + + +class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): + """Schedule Prompt-Control LoRAs and encode prompts with optional batches.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the Prompt-Control schedule and encode schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl", + display_name="Schedule & Encode Prompts", + enable_expand=True, + category="SimpleSyrup/Conditioning", + description=( + "Schedules Prompt-Control LoRAs and encodes prompts, using [SEP] " + "to create SimpleSyrup conditioning batches." + ), + inputs=[ + _comfy_io.Model.Input( + "model", + raw_link=True, + tooltip=( + "Model that receives LoRA changes found in Prompt-Control " + "prompt tags." + ), + ), + _comfy_io.Clip.Input( + "clip", + raw_link=True, + tooltip=( + "CLIP connection used for scheduled hooks and cleaned " + "prompt encoding." + ), + ), + _comfy_io.String.Input( + "encode_style", + default="", + force_input=True, + optional=True, + tooltip=( + "Encode style text from Prompt Encode Style or " + "Prompt Encode Style & Normalization." + ), + ), + _comfy_io.String.Input( + "positive_prompt", + multiline=False, + default="", + tooltip=( + "Positive Prompt-Control text. [SEP] creates a " + "conditioning batch for SimpleSyrup batch-aware nodes." + ), + ), + _comfy_io.String.Input( + "negative_prompt", + multiline=False, + default="", + tooltip=( + "Negative Prompt-Control text. [SEP] creates a " + "conditioning batch for SimpleSyrup batch-aware nodes." + ), + ), + ], + outputs=[ + _comfy_io.Model.Output( + "model", + tooltip=( + "Model after LoRA tags from positive and negative prompts " + "are scheduled." + ), + ), + MixedConditioningIO.Output( + "positive", + tooltip="Positive conditioning or SimpleSyrup conditioning batch.", + ), + MixedConditioningIO.Output( + "negative", + tooltip="Negative conditioning or SimpleSyrup conditioning batch.", + ), + ], + ) + + @classmethod + def execute( + cls, + model: Any, + clip: Any, + positive_prompt: str, + negative_prompt: str, + encode_style: str = "", + ) -> Any: + """Build lazy Prompt-Control graph expansion for prompts.""" + + return PromptControlScheduleEncodeGraphBuilder().build( + model=model, + clip=clip, + positive_prompt=positive_prompt, + negative_prompt=negative_prompt, + encode_style=encode_style, + ) diff --git a/simple_syrup/runtime/prompt_control_schedule_encode_graph.py b/simple_syrup/runtime/prompt_control_schedule_encode_graph.py new file mode 100644 index 0000000..4a34052 --- /dev/null +++ b/simple_syrup/runtime/prompt_control_schedule_encode_graph.py @@ -0,0 +1,195 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Runtime graph expansion for Prompt-Control scheduling and prompt encoding.""" + +from __future__ import annotations + +import sys +from importlib import import_module +from typing import Any, cast + +from ..domain.prompt_control_prompt import ( + PreparedPromptSide, + apply_encode_style, + prepare_prompt_side, +) +from .prompt_control_availability import find_prompt_control_install + +PROMPT_CONTROL_MISSING_MESSAGE = ( + "Schedule & Encode Prompts requires comfyui-prompt-control. " + "Install Prompt Control or remove this node from the workflow." +) +PROMPT_BATCH_SEPARATOR = "[SEP]" + + +class PromptControlScheduleEncodeGraphBuilder: + """Build lazy Prompt-Control graphs for LoRA scheduling and prompt encoding.""" + + def build( + self, + model: Any, + clip: Any, + positive_prompt: str, + negative_prompt: str, + encode_style: str = "", + ) -> Any: + """Return an io.NodeOutput for scheduled model and encoded prompts.""" + + io, graph_utils, lazy_nodes = self._prompt_control_dependencies() + positive_side = prepare_prompt_side(positive_prompt, PROMPT_BATCH_SEPARATOR) + negative_side = prepare_prompt_side(negative_prompt, PROMPT_BATCH_SEPARATOR) + + expand: dict[str, dict[str, Any]] = {} + positive_lora = lazy_nodes.PCLazyLoraLoaderAdvanced.execute( + model=model, + clip=clip, + text=positive_side.lora_tags, + apply_hooks=True, + tags="", + start=0.0, + end=1.0, + num_steps=0, + ) + self._merge_expand(expand, positive_lora.expand, "positive LoRA scheduling") + + negative_lora = lazy_nodes.PCLazyLoraLoaderAdvanced.execute( + model=positive_lora.args[0], + clip=positive_lora.args[1], + text=negative_side.lora_tags, + apply_hooks=True, + tags="", + start=0.0, + end=1.0, + num_steps=0, + ) + self._merge_expand(expand, negative_lora.expand, "negative LoRA scheduling") + + scheduled_model = negative_lora.args[0] + scheduled_clip = negative_lora.args[1] + positive_conditioning = self._encode_side( + side=positive_side, + clip=scheduled_clip, + encode_style=encode_style, + graph_utils=graph_utils, + lazy_text_encoder=lazy_nodes.PCLazyTextEncodeAdvanced, + expand=expand, + label="positive prompt encoding", + ) + negative_conditioning = self._encode_side( + side=negative_side, + clip=scheduled_clip, + encode_style=encode_style, + graph_utils=graph_utils, + lazy_text_encoder=lazy_nodes.PCLazyTextEncodeAdvanced, + expand=expand, + label="negative prompt encoding", + ) + + return io.NodeOutput( + scheduled_model, + positive_conditioning, + negative_conditioning, + expand=expand, + ) + + def _encode_side( + self, + *, + side: PreparedPromptSide, + clip: Any, + encode_style: str, + graph_utils: Any, + lazy_text_encoder: Any, + expand: dict[str, dict[str, Any]], + label: str, + ) -> Any: + """Encode one prompt side and return conditioning or conditioning batch.""" + + conditioning_outputs: list[Any] = [] + for index, chunk in enumerate(side.chunks): + text = apply_encode_style(encode_style, chunk.text) + node_output = lazy_text_encoder.execute( + clip=clip, + text=text, + tags="", + start=0.0, + end=1.0, + num_steps=0, + ) + self._merge_expand( + expand, + node_output.expand, + f"{label} chunk {index}", + ) + conditioning_outputs.append(node_output.args[0]) + + if len(conditioning_outputs) == 1: + return conditioning_outputs[0] + + pack_graph = graph_utils.GraphBuilder() + current = pack_graph.node( + "SimpleSyrup.ConditioningBatchStart", + conditioning=conditioning_outputs[0], + ) + for conditioning in conditioning_outputs[1:]: + current = pack_graph.node( + "SimpleSyrup.ConditioningBatchAppend", + batch=current.out(0), + conditioning=conditioning, + ) + self._merge_expand( + expand, + cast(dict[str, dict[str, Any]], pack_graph.finalize()), + f"{label} batch packing", + ) + return current.out(0) + + def _prompt_control_dependencies(self) -> tuple[Any, Any, Any]: + """Import Prompt-Control and Comfy graph helpers on demand.""" + + try: + io = import_module("comfy_api.latest.io") + except ModuleNotFoundError: + comfy_api = import_module("comfy_api.latest") + io = comfy_api.io + try: + graph_utils = import_module("comfy_execution.graph_utils") + lazy_nodes = self._import_prompt_control_lazy_nodes() + except ModuleNotFoundError as exc: + raise RuntimeError(PROMPT_CONTROL_MISSING_MESSAGE) from exc + return io, graph_utils, lazy_nodes + + def _import_prompt_control_lazy_nodes(self) -> Any: + """Import Prompt-Control lazy nodes from installed or sibling paths.""" + + try: + return import_module("prompt_control.nodes_lazy") + except ModuleNotFoundError: + availability = find_prompt_control_install() + if availability.root_path is not None: + root_path = str(availability.root_path) + if root_path not in sys.path: + sys.path.insert(0, root_path) + return import_module("prompt_control.nodes_lazy") + + def _merge_expand( + self, + target: dict[str, dict[str, Any]], + source: object, + operation: str, + ) -> None: + """Merge a lazy expand graph and reject duplicate generated node ids.""" + + if not source: + return + expand = cast(dict[str, dict[str, Any]], source) + overlap = set(target).intersection(expand) + if overlap: + overlapping_ids = ", ".join(sorted(overlap)) + raise ValueError( + f"Prompt-Control graph expansion generated duplicate node ids " + f"during {operation}: {overlapping_ids}." + ) + target.update(expand) diff --git a/simple_syrup/services/tiled_diffusion_sampling_service.py b/simple_syrup/services/tiled_diffusion_sampling_service.py index 2688779..33d9738 100644 --- a/simple_syrup/services/tiled_diffusion_sampling_service.py +++ b/simple_syrup/services/tiled_diffusion_sampling_service.py @@ -8,6 +8,9 @@ from __future__ import annotations from typing import Any +import torch + +from ..domain.conditioning_batch import ConditioningBatch, select_conditioning from ..domain.tiled_diffusion import validate_tiled_diffusion_mode from ..runtime import mixture_of_diffusers_sampling, multidiffusion_sampling from ..runtime.detail_previews import DetailPreviewContext @@ -42,6 +45,26 @@ class TiledDiffusionSamplingService: """Sample a latent with the selected tiled diffusion method.""" validate_tiled_diffusion_mode(diffusion_mode) + if self._uses_conditioning_batch(positive, negative): + return self._sample_conditioning_batch( + diffusion_mode=diffusion_mode, + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=preview_context, + differential_diffusion=differential_diffusion, + ) if diffusion_mode == "multidiffusion": return multidiffusion_sampling.sample_multidiffusion( model=model, @@ -79,3 +102,104 @@ class TiledDiffusionSamplingService: preview_context=preview_context, differential_diffusion=differential_diffusion, ) + + def _sample_conditioning_batch( + self, + *, + diffusion_mode: str, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None, + differential_diffusion: bool, + ) -> Latent: + """Sample latent batch items one at a time with selected conditioning.""" + + latent_samples = latent_image["samples"] + if not isinstance(latent_samples, torch.Tensor): + raise TypeError("Tiled diffusion latent samples must be a torch.Tensor.") + + outputs: list[torch.Tensor] = [] + for index in range(int(latent_samples.shape[0])): + item_latent = self._single_item_latent(latent_image, index) + output = self.sample( + diffusion_mode=diffusion_mode, + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=select_conditioning(positive, index), + negative=select_conditioning(negative, index), + latent_image=item_latent, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=preview_context, + differential_diffusion=differential_diffusion, + ) + output_samples = output["samples"] + if not isinstance(output_samples, torch.Tensor): + raise TypeError( + "Tiled diffusion output samples must be a torch.Tensor." + ) + outputs.append(output_samples) + + result = latent_image.copy() + result.pop("downscale_ratio_spacial", None) + result["samples"] = torch.cat(outputs, dim=0) + return result + + def _single_item_latent(self, latent_image: Latent, index: int) -> Latent: + """Return a latent dictionary for one batch item.""" + + latent_samples = latent_image["samples"] + if not isinstance(latent_samples, torch.Tensor): + raise TypeError("Tiled diffusion latent samples must be a torch.Tensor.") + item = latent_image.copy() + item["samples"] = latent_samples[index : index + 1] + if "batch_index" in item: + item["batch_index"] = [item["batch_index"][index]] + if "noise_mask" in item: + item["noise_mask"] = self._slice_noise_mask( + item["noise_mask"], + index, + latent_samples, + ) + return item + + def _slice_noise_mask( + self, + noise_mask: Any, + index: int, + latent_samples: torch.Tensor, + ) -> Any: + """Return the noise mask slice matching one latent batch item.""" + + if isinstance(noise_mask, torch.Tensor) and noise_mask.shape[0] == int( + latent_samples.shape[0], + ): + return noise_mask[index : index + 1] + return noise_mask + + def _uses_conditioning_batch(self, positive: Any, negative: Any) -> bool: + """Return whether tiled sampling needs per-item conditioning selection.""" + + return isinstance(positive, ConditioningBatch) or isinstance( + negative, + ConditioningBatch, + ) diff --git a/tests/test_ksampler_extras_node.py b/tests/test_ksampler_extras_node.py index 79a7776..dd65dee 100644 --- a/tests/test_ksampler_extras_node.py +++ b/tests/test_ksampler_extras_node.py @@ -12,6 +12,7 @@ from typing import Any import torch +from simple_syrup.domain.conditioning_batch import ConditioningBatch from simple_syrup.nodes.ksampler_extras import KSamplerExtras from simple_syrup.runtime import sampling_samplers, sampling_schedulers @@ -63,6 +64,8 @@ def test_input_types_match_simple_ksampler_contract() -> None: "latent_image", "denoise", ) + assert required["positive"][0] == "CONDITIONING,CONDITIONING_BATCH" + assert required["negative"][0] == "CONDITIONING,CONDITIONING_BATCH" def test_node_metadata_matches_contract() -> None: @@ -289,3 +292,107 @@ def test_sample_delegates_to_runtime_helpers( assert calls["sample_custom"]["sampler"] is sampler assert calls["sample_custom"]["sigmas"] is fixed_sigmas assert calls["sample_custom"]["disable_pbar"] is True + + +def test_sample_selects_conditioning_batch_per_latent_item( + monkeypatch: Any, +) -> None: + """Conditioning batches are selected before calling Comfy sampling.""" + + calls: list[dict[str, Any]] = [] + model = FakeModel() + sampler = FakeSampler() + latent_samples = torch.arange(2 * 4 * 2 * 2, dtype=torch.float32).reshape( + (2, 4, 2, 2) + ) + fixed_noise = torch.full_like(latent_samples, 2.0) + fixed_sigmas = torch.tensor([1.0, 0.0], dtype=torch.float32) + noise_mask = torch.ones((2, 1, 2, 2), dtype=torch.float32) + latent_image: dict[str, Any] = { + "samples": latent_samples, + "batch_index": [4, 9], + "noise_mask": noise_mask, + } + + monkeypatch.setattr( + sampling_samplers, + "resolve_sampler", + lambda sampler_name: sampler, + ) + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + lambda **kwargs: fixed_sigmas, + ) + monkeypatch.setattr( + comfy_sample, + "fix_empty_latent_channels", + lambda model, samples, downscale_ratio_spacial: samples, + ) + monkeypatch.setattr( + comfy_sample, + "prepare_noise", + lambda samples, seed, batch_inds: fixed_noise, + ) + monkeypatch.setattr( + latent_preview, + "prepare_callback", + lambda received_model, steps: "callback", + ) + + def fake_sample_custom( + received_model: FakeModel, + noise: torch.Tensor, + cfg: float, + received_sampler: FakeSampler, + sigmas: torch.Tensor, + positive: object, + negative: object, + latent_image: torch.Tensor, + noise_mask: torch.Tensor | None, + callback: str, + disable_pbar: bool, + seed: int, + ) -> torch.Tensor: + """Record one per-item sample call and return a marked tensor.""" + + del received_model, cfg, received_sampler, sigmas, callback, disable_pbar, seed + calls.append( + { + "noise": noise, + "positive": positive, + "negative": negative, + "latent_image": latent_image, + "noise_mask": noise_mask, + } + ) + return torch.full_like(latent_image, float(len(calls))) + + monkeypatch.setattr(comfy_sample, "sample_custom", fake_sample_custom) + monkeypatch.setattr(comfy_utils, "PROGRESS_BAR_ENABLED", False) + + (output,) = KSamplerExtras().sample( + model=model, + seed=123, + steps=2, + cfg=7.5, + sampler_name="lcm", + scheduler="GITS", + positive=ConditioningBatch(("positive-0", "positive-1")), + negative=ConditioningBatch(("negative-last",)), + latent_image=latent_image, + denoise=0.8, + ) + + assert len(calls) == 2 + assert calls[0]["positive"] == "positive-0" + assert calls[1]["positive"] == "positive-1" + assert calls[0]["negative"] == "negative-last" + assert calls[1]["negative"] == "negative-last" + assert torch.equal(calls[0]["noise"], fixed_noise[0:1]) + assert torch.equal(calls[1]["noise"], fixed_noise[1:2]) + assert torch.equal(calls[0]["noise_mask"], noise_mask[0:1]) + assert torch.equal(calls[1]["noise_mask"], noise_mask[1:2]) + assert output["samples"].shape == latent_samples.shape + assert torch.equal(output["samples"][0], torch.full((4, 2, 2), 1.0)) + assert torch.equal(output["samples"][1], torch.full((4, 2, 2), 2.0)) diff --git a/tests/test_ksampler_tiled_diffusion_node.py b/tests/test_ksampler_tiled_diffusion_node.py index 5d59e2b..bd771b5 100644 --- a/tests/test_ksampler_tiled_diffusion_node.py +++ b/tests/test_ksampler_tiled_diffusion_node.py @@ -50,6 +50,8 @@ def test_input_types_match_tiled_diffusion_contract( "mixture_of_diffusers", ] assert required["diffusion_mode"][1]["default"] == "multidiffusion" + assert required["positive"][0] == "CONDITIONING,CONDITIONING_BATCH" + assert required["negative"][0] == "CONDITIONING,CONDITIONING_BATCH" assert required["latent_tile_width"][1]["default"] == 128 assert required["latent_tile_width"][1]["max"] == 512 assert required["latent_tile_height"][1]["default"] == 128 diff --git a/tests/test_node_tooltips.py b/tests/test_node_tooltips.py index dc06039..003887e 100644 --- a/tests/test_node_tooltips.py +++ b/tests/test_node_tooltips.py @@ -18,6 +18,9 @@ from simple_syrup.nodes_v3.encode_prompt_batch_with_prompt_control import ( EncodePromptBatchWithPromptControl, ) from simple_syrup.nodes_v3.scale_factor import ScaleFactorV3 +from simple_syrup.nodes_v3.schedule_and_encode_prompts_with_prompt_control import ( + ScheduleAndEncodePromptsWithPromptControl, +) from simple_syrup.nodes_v3.simple_load_checkpoint import SimpleLoadCheckpointV3 from simple_syrup.nodes_v3.tile_and_tag_segs import TileAndTagSEGSV3 from simple_syrup.nodes_v3.vae_decode_options import VAEDecodeOptionsV3 @@ -122,6 +125,7 @@ def test_legacy_named_outputs_provide_tooltips() -> None: VAEEncodeOptionsV3, WD14TaggerLoaderV3, EncodePromptBatchWithPromptControl, + ScheduleAndEncodePromptsWithPromptControl, ], ) def test_v3_nodes_provide_tooltip_metadata( diff --git a/tests/test_prompt_control_prompt.py b/tests/test_prompt_control_prompt.py new file mode 100644 index 0000000..af1ccaf --- /dev/null +++ b/tests/test_prompt_control_prompt.py @@ -0,0 +1,83 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Prompt-Control prompt preparation helpers.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.prompt_control_prompt import ( + apply_encode_style, + extract_lora_tags, + extract_prompt_text, + prepare_prompt_side, +) + + +def test_extract_prompt_text_matches_sugarcubes_regex_behavior() -> None: + """Visible text outside angle tags is retained and joined with newlines.""" + + text = "portrait cinematic " + + assert extract_prompt_text(text) == "portrait \n cinematic " + + +def test_extract_prompt_text_keeps_text_after_adjacent_tag() -> None: + """Text after a closing angle tag is captured as a later prompt segment.""" + + assert extract_prompt_text("face") == "face" + + +def test_extract_lora_tags_returns_angle_tags_joined_with_newlines() -> None: + """All Prompt-Control angle tags are retained for LoRA scheduling.""" + + text = "portrait cinematic " + + assert extract_lora_tags(text) == "\n" + + +def test_prepare_prompt_side_splits_ordered_chunks_and_aggregates_loras() -> None: + """Separator-delimited chunks preserve order and collect all tags.""" + + side = prepare_prompt_side( + "face [SEP] hair ", + "[SEP]", + ) + + assert [chunk.text for chunk in side.chunks] == ["face ", "hair "] + assert [chunk.lora_tags for chunk in side.chunks] == [ + "", + "", + ] + assert side.lora_tags == "\n" + + +def test_prepare_prompt_side_preserves_empty_chunks() -> None: + """Batch splitting keeps empty entries so batch positions stay explicit.""" + + side = prepare_prompt_side("face [SEP] ", "[SEP]") + + assert [chunk.text for chunk in side.chunks] == ["face", ""] + assert [chunk.lora_tags for chunk in side.chunks] == ["", ""] + assert side.lora_tags == "" + + +def test_prepare_prompt_side_rejects_empty_separator() -> None: + """Empty separators are rejected by the shared batch splitter.""" + + with pytest.raises(ValueError, match="separator must not be empty"): + prepare_prompt_side("face", "") + + +def test_apply_encode_style_prepends_style_without_extra_formatting() -> None: + """Encode style text is used exactly as produced by style nodes.""" + + assert apply_encode_style("STYLE(A1111) ", "face") == "STYLE(A1111) face" + + +def test_apply_encode_style_keeps_prompt_when_style_is_blank() -> None: + """Blank encode style leaves cleaned prompt text unchanged.""" + + assert apply_encode_style("", "face") == "face" diff --git a/tests/test_prompt_control_schedule_encode_graph.py b/tests/test_prompt_control_schedule_encode_graph.py new file mode 100644 index 0000000..91f5f8c --- /dev/null +++ b/tests/test_prompt_control_schedule_encode_graph.py @@ -0,0 +1,224 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for Prompt-Control schedule and encode lazy graph expansion.""" + +from __future__ import annotations + +import sys +from importlib import import_module +from types import ModuleType +from typing import Any, cast + +import pytest + +from simple_syrup.runtime.prompt_control_schedule_encode_graph import ( + PROMPT_CONTROL_MISSING_MESSAGE, + PromptControlScheduleEncodeGraphBuilder, +) + + +def test_schedule_encode_graph_builds_single_conditioning_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Single prompts return direct Prompt-Control conditioning links.""" + + calls = _install_fake_prompt_control(monkeypatch) + + output = PromptControlScheduleEncodeGraphBuilder().build( + model=["model", 0], + clip=["clip", 0], + positive_prompt="face ", + negative_prompt="blur ", + encode_style="STYLE(A1111) ", + ) + + assert output.args[0] == ["lora_negative", 0] + assert output.args[1] == ["encode_0", 0] + assert output.args[2] == ["encode_1", 0] + assert output.expand is not None + assert not any( + node["class_type"].startswith("SimpleSyrup.ConditioningBatch") + for node in output.expand.values() + ) + assert calls["lora"] == [ + { + "model": ["model", 0], + "clip": ["clip", 0], + "text": "", + }, + { + "model": ["lora_positive", 0], + "clip": ["lora_positive", 1], + "text": "", + }, + ] + assert calls["encode"] == [ + {"clip": ["lora_negative", 1], "text": "STYLE(A1111) face "}, + {"clip": ["lora_negative", 1], "text": "STYLE(A1111) blur "}, + ] + + +def test_schedule_encode_graph_packs_only_multichunk_sides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A side with multiple chunks becomes a conditioning batch.""" + + _install_fake_prompt_control(monkeypatch) + + output = PromptControlScheduleEncodeGraphBuilder().build( + model=["model", 0], + clip=["clip", 0], + positive_prompt="face [SEP] hair", + negative_prompt="blur", + ) + + assert output.args[1] != ["encode_0", 0] + assert output.args[2] == ["encode_2", 0] + assert output.expand is not None + pack_nodes = [ + node + for node in output.expand.values() + if node["class_type"].startswith("SimpleSyrup.ConditioningBatch") + ] + assert [node["class_type"] for node in pack_nodes] == [ + "SimpleSyrup.ConditioningBatchStart", + "SimpleSyrup.ConditioningBatchAppend", + ] + + +def test_schedule_encode_graph_collects_loras_from_all_chunks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """LoRA scheduling sees all tags from every separator-delimited chunk.""" + + calls = _install_fake_prompt_control(monkeypatch) + + PromptControlScheduleEncodeGraphBuilder().build( + model=["model", 0], + clip=["clip", 0], + positive_prompt="face [SEP] hair ", + negative_prompt="blur [SEP] noise ", + ) + + assert calls["lora"][0]["text"] == "\n" + assert calls["lora"][1]["text"] == "\n" + + +def test_schedule_encode_graph_reports_duplicate_expand_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Duplicate generated node ids are rejected with context.""" + + _install_fake_prompt_control(monkeypatch, duplicate_lora_ids=True) + + with pytest.raises(ValueError, match="negative LoRA scheduling"): + PromptControlScheduleEncodeGraphBuilder().build( + model=["model", 0], + clip=["clip", 0], + positive_prompt="face", + negative_prompt="blur", + ) + + +def test_schedule_encode_graph_reports_missing_prompt_control( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing Prompt-Control dependency raises an actionable error.""" + + def fake_import_module(name: str) -> Any: + if name == "prompt_control.nodes_lazy": + raise ModuleNotFoundError(name) + return import_module(name) + + monkeypatch.setattr( + "simple_syrup.runtime.prompt_control_schedule_encode_graph.import_module", + fake_import_module, + ) + + with pytest.raises(RuntimeError, match="requires comfyui-prompt-control"): + PromptControlScheduleEncodeGraphBuilder().build( + model=["model", 0], + clip=["clip", 0], + positive_prompt="face", + negative_prompt="blur", + ) + assert PROMPT_CONTROL_MISSING_MESSAGE.startswith("Schedule & Encode Prompts") + + +def _install_fake_prompt_control( + monkeypatch: pytest.MonkeyPatch, + *, + duplicate_lora_ids: bool = False, +) -> dict[str, list[dict[str, Any]]]: + """Install graph-expanding Prompt-Control stand-ins for builder tests.""" + + prompt_control = ModuleType("prompt_control") + nodes_lazy = ModuleType("prompt_control.nodes_lazy") + io = import_module("comfy_api.latest").io + calls: dict[str, list[dict[str, Any]]] = {"lora": [], "encode": []} + + class FakePCLazyLoraLoaderAdvanced: + """Prompt-Control LoRA scheduler test double.""" + + @staticmethod + def execute( + model: Any, + clip: Any, + text: str, + apply_hooks: bool, + tags: str, + start: float, + end: float, + num_steps: int, + ) -> Any: + """Return deterministic model and clip links.""" + + assert apply_hooks is True + assert tags == "" + assert start == 0.0 + assert end == 1.0 + assert num_steps == 0 + side = "positive" if not calls["lora"] else "negative" + calls["lora"].append({"model": model, "clip": clip, "text": text}) + node_id = "duplicate_lora" if duplicate_lora_ids else f"lora_{side}" + return io.NodeOutput( + [f"lora_{side}", 0], + [f"lora_{side}", 1], + None, + expand={node_id: {"class_type": "PromptControl.FakeLora"}}, + ) + + class FakePCLazyTextEncodeAdvanced: + """Prompt-Control text encoder test double.""" + + @staticmethod + def execute( + clip: Any, + text: str, + tags: str, + start: float, + end: float, + num_steps: int, + ) -> Any: + """Return deterministic conditioning links.""" + + assert tags == "" + assert start == 0.0 + assert end == 1.0 + assert num_steps == 0 + index = len(calls["encode"]) + calls["encode"].append({"clip": clip, "text": text}) + node_id = f"encode_{index}" + return io.NodeOutput( + [node_id, 0], + expand={node_id: {"class_type": "PromptControl.FakeTextEncode"}}, + ) + + cast(Any, nodes_lazy).PCLazyLoraLoaderAdvanced = FakePCLazyLoraLoaderAdvanced + cast(Any, nodes_lazy).PCLazyTextEncodeAdvanced = FakePCLazyTextEncodeAdvanced + cast(Any, prompt_control).nodes_lazy = nodes_lazy + monkeypatch.setitem(sys.modules, "prompt_control", prompt_control) + monkeypatch.setitem(sys.modules, "prompt_control.nodes_lazy", nodes_lazy) + return calls diff --git a/tests/test_prompt_encode_style_nodes.py b/tests/test_prompt_encode_style_nodes.py index 7d6da2f..13271ba 100644 --- a/tests/test_prompt_encode_style_nodes.py +++ b/tests/test_prompt_encode_style_nodes.py @@ -29,7 +29,7 @@ def test_prompt_encode_style_node_contract_constants() -> None: """Style-only node constants match the public ComfyUI contract.""" assert PromptEncodeStyle.RETURN_TYPES == ("STRING",) - assert PromptEncodeStyle.RETURN_NAMES == ("style_tag",) + assert PromptEncodeStyle.RETURN_NAMES == ("encode_style",) assert PromptEncodeStyle.FUNCTION == "build" assert PromptEncodeStyle.CATEGORY == "SimpleSyrup/Prompt" @@ -60,18 +60,18 @@ def test_prompt_encode_style_node_builds_style_tag( ) -> None: """Style-only node formats Prompt Control STYLE tags from combo values.""" - (style_tag,) = PromptEncodeStyle().build(encode_style) + (encode_style_text,) = PromptEncodeStyle().build(encode_style) - assert style_tag == expected - assert style_tag.endswith(" ") - assert not style_tag.endswith(" ") + assert encode_style_text == expected + assert encode_style_text.endswith(" ") + assert not encode_style_text.endswith(" ") def test_prompt_encode_style_and_normalization_node_contract_constants() -> None: """Normalization node constants match the public ComfyUI contract.""" assert PromptEncodeStyleAndNormalization.RETURN_TYPES == ("STRING",) - assert PromptEncodeStyleAndNormalization.RETURN_NAMES == ("style_tag",) + assert PromptEncodeStyleAndNormalization.RETURN_NAMES == ("encode_style",) assert PromptEncodeStyleAndNormalization.FUNCTION == "build" assert PromptEncodeStyleAndNormalization.CATEGORY == "SimpleSyrup/Prompt" @@ -110,10 +110,10 @@ def test_prompt_encode_style_and_normalization_node_builds_style_tag( ) -> None: """Normalization node formats Prompt Control STYLE tags from combo values.""" - (style_tag,) = PromptEncodeStyleAndNormalization().build( + (encode_style_text,) = PromptEncodeStyleAndNormalization().build( encode_style, normalization ) - assert style_tag == expected - assert style_tag.endswith(" ") - assert not style_tag.endswith(" ") + assert encode_style_text == expected + assert encode_style_text.endswith(" ") + assert not encode_style_text.endswith(" ") diff --git a/tests/test_registration.py b/tests/test_registration.py index d550fb5..350554f 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -15,6 +15,8 @@ from types import ModuleType import pytest +from simple_syrup.runtime.prompt_control_availability import prompt_control_is_available + def test_package_exports_node_mappings() -> None: """Root package import exposes ComfyUI mapping dictionaries.""" @@ -189,6 +191,28 @@ def test_prompt_control_encode_style_clean_break_id_is_removed() -> None: ) +def test_prompt_control_schedule_node_is_legacy_registered_when_available() -> None: + """Prompt-Control schedule node is exported through legacy mappings.""" + + if not prompt_control_is_available(): + pytest.skip("Prompt-Control is not installed in this test environment.") + + package = importlib.import_module("SimpleSyrup") + nodes_module = importlib.import_module("SimpleSyrup.simple_syrup.nodes") + + registered = package.NODE_CLASS_MAPPINGS[ + "SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl" + ] + assert registered.__name__ == "ScheduleAndEncodePromptsWithPromptControl" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS[ + "SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl" + ] + == "Schedule & Encode Prompts" + ) + assert "ScheduleAndEncodePromptsWithPromptControl" in nodes_module.__all__ + + def test_prompt_segs_with_sam_node_is_registered() -> None: """Prompt SEGS w/ SAM node id maps to its class and display name.""" @@ -530,6 +554,7 @@ def test_v3_entrypoint_registers_tile_and_prompt_control_batch_nodes( "VAEDecodeOptionsV3", "VAEEncodeOptionsV3", "EncodePromptBatchWithPromptControl", + "ScheduleAndEncodePromptsWithPromptControl", ] assert "prompt_control.nodes_lazy" not in sys.modules diff --git a/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py b/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py new file mode 100644 index 0000000..0c54e30 --- /dev/null +++ b/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py @@ -0,0 +1,273 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Prompt-Control schedule and encode node schema.""" + +from __future__ import annotations + +from typing import Any + +from simple_syrup.nodes.schedule_and_encode_prompts_with_prompt_control import ( + ScheduleAndEncodePromptsWithPromptControl as LegacyScheduleAndEncode, +) +from simple_syrup.nodes_v3.schedule_and_encode_prompts_with_prompt_control import ( + ScheduleAndEncodePromptsWithPromptControl, +) + + +def test_legacy_schedule_and_encode_prompt_control_node_contract() -> None: + """The legacy node exposes the same workflow-facing contract.""" + + inputs = LegacyScheduleAndEncode.INPUT_TYPES() + + assert LegacyScheduleAndEncode.RETURN_TYPES == ( + "MODEL", + "CONDITIONING,CONDITIONING_BATCH", + "CONDITIONING,CONDITIONING_BATCH", + ) + assert LegacyScheduleAndEncode.RETURN_NAMES == ( + "model", + "positive", + "negative", + ) + assert LegacyScheduleAndEncode.FUNCTION == "execute" + assert LegacyScheduleAndEncode.CATEGORY == "SimpleSyrup/Conditioning" + assert list(inputs["required"]) == [ + "model", + "clip", + "positive_prompt", + "negative_prompt", + ] + assert list(inputs["optional"]) == ["encode_style"] + assert inputs["required"]["model"][0] == "MODEL" + assert inputs["required"]["clip"][0] == "CLIP" + assert inputs["optional"]["encode_style"][0] == "STRING" + assert inputs["optional"]["encode_style"][1]["forceInput"] is True + assert inputs["required"]["positive_prompt"][1]["multiline"] is False + assert inputs["required"]["negative_prompt"][1]["multiline"] is False + + +def test_legacy_schedule_and_encode_prompt_control_execute_delegates( + monkeypatch: Any, +) -> None: + """Legacy node execution delegates to the shared runtime builder.""" + + calls: list[dict[str, Any]] = [] + + class FakeBuilder: + """Runtime builder double.""" + + def build(self, **kwargs: Any) -> str: + """Record builder arguments and return a fixed output.""" + + calls.append(kwargs) + return "legacy-node-output" + + monkeypatch.setattr( + "simple_syrup.nodes.schedule_and_encode_prompts_with_prompt_control." + "PromptControlScheduleEncodeGraphBuilder", + FakeBuilder, + ) + + output = LegacyScheduleAndEncode().execute( + model="model", + clip="clip", + encode_style="STYLE(A1111) ", + positive_prompt="positive", + negative_prompt="negative", + ) + + assert output == "legacy-node-output" + assert calls == [ + { + "model": "model", + "clip": "clip", + "encode_style": "STYLE(A1111) ", + "positive_prompt": "positive", + "negative_prompt": "negative", + } + ] + + +def test_legacy_schedule_and_encode_prompt_control_omits_encode_style( + monkeypatch: Any, +) -> None: + """Legacy node execution no-ops style behavior when the optional socket is empty.""" + + calls: list[dict[str, Any]] = [] + + class FakeBuilder: + """Runtime builder double.""" + + def build(self, **kwargs: Any) -> str: + """Record builder arguments and return a fixed output.""" + + calls.append(kwargs) + return "legacy-node-output" + + monkeypatch.setattr( + "simple_syrup.nodes.schedule_and_encode_prompts_with_prompt_control." + "PromptControlScheduleEncodeGraphBuilder", + FakeBuilder, + ) + + output = LegacyScheduleAndEncode().execute( + model="model", + clip="clip", + positive_prompt="positive", + negative_prompt="negative", + ) + + assert output == "legacy-node-output" + assert calls == [ + { + "model": "model", + "clip": "clip", + "encode_style": "", + "positive_prompt": "positive", + "negative_prompt": "negative", + } + ] + + +def test_schedule_and_encode_prompt_control_node_schema() -> None: + """The v3 node exposes the planned schedule and encode contract.""" + + schema = ScheduleAndEncodePromptsWithPromptControl.define_schema() + + assert schema.node_id == "SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl" + assert schema.display_name == "Schedule & Encode Prompts" + assert schema.enable_expand is True + assert schema.category == "SimpleSyrup/Conditioning" + assert [output.io_type for output in schema.outputs] == [ + "MODEL", + "CONDITIONING,CONDITIONING_BATCH", + "CONDITIONING,CONDITIONING_BATCH", + ] + assert [output.id for output in schema.outputs] == [ + "model", + "positive", + "negative", + ] + assert [input_item.id for input_item in schema.inputs] == [ + "model", + "clip", + "encode_style", + "positive_prompt", + "negative_prompt", + ] + assert schema.inputs[2].optional is True + + +def test_schedule_and_encode_prompt_control_input_types() -> None: + """The finalized v3 schema exposes Comfy-compatible sockets.""" + + inputs = ScheduleAndEncodePromptsWithPromptControl.INPUT_TYPES() + + assert ScheduleAndEncodePromptsWithPromptControl.RETURN_TYPES == [ + "MODEL", + "CONDITIONING,CONDITIONING_BATCH", + "CONDITIONING,CONDITIONING_BATCH", + ] + assert ScheduleAndEncodePromptsWithPromptControl.RETURN_NAMES == [ + "model", + "positive", + "negative", + ] + assert list(inputs["required"]) == [ + "model", + "clip", + "positive_prompt", + "negative_prompt", + ] + assert list(inputs["optional"]) == ["encode_style"] + assert inputs["required"]["model"][0] == "MODEL" + assert inputs["required"]["clip"][0] == "CLIP" + assert inputs["optional"]["encode_style"][0] == "STRING" + assert inputs["optional"]["encode_style"][1]["forceInput"] is True + assert inputs["required"]["positive_prompt"][1]["multiline"] is False + assert inputs["required"]["negative_prompt"][1]["multiline"] is False + + +def test_schedule_and_encode_prompt_control_execute_delegates( + monkeypatch: Any, +) -> None: + """Node execution delegates behavior to the runtime graph builder.""" + + calls: list[dict[str, Any]] = [] + + class FakeBuilder: + """Runtime builder double.""" + + def build(self, **kwargs: Any) -> str: + """Record builder arguments and return a fixed output.""" + + calls.append(kwargs) + return "node-output" + + monkeypatch.setattr( + "simple_syrup.nodes_v3.schedule_and_encode_prompts_with_prompt_control." + "PromptControlScheduleEncodeGraphBuilder", + FakeBuilder, + ) + + output = ScheduleAndEncodePromptsWithPromptControl.execute( + model="model", + clip="clip", + encode_style="STYLE(A1111) ", + positive_prompt="positive", + negative_prompt="negative", + ) + + assert output == "node-output" + assert calls == [ + { + "model": "model", + "clip": "clip", + "encode_style": "STYLE(A1111) ", + "positive_prompt": "positive", + "negative_prompt": "negative", + } + ] + + +def test_schedule_and_encode_prompt_control_omits_encode_style( + monkeypatch: Any, +) -> None: + """Node execution no-ops style behavior when the optional socket is empty.""" + + calls: list[dict[str, Any]] = [] + + class FakeBuilder: + """Runtime builder double.""" + + def build(self, **kwargs: Any) -> str: + """Record builder arguments and return a fixed output.""" + + calls.append(kwargs) + return "node-output" + + monkeypatch.setattr( + "simple_syrup.nodes_v3.schedule_and_encode_prompts_with_prompt_control." + "PromptControlScheduleEncodeGraphBuilder", + FakeBuilder, + ) + + output = ScheduleAndEncodePromptsWithPromptControl.execute( + model="model", + clip="clip", + positive_prompt="positive", + negative_prompt="negative", + ) + + assert output == "node-output" + assert calls == [ + { + "model": "model", + "clip": "clip", + "encode_style": "", + "positive_prompt": "positive", + "negative_prompt": "negative", + } + ] diff --git a/tests/test_tiled_diffusion_sampling_service.py b/tests/test_tiled_diffusion_sampling_service.py index fa616d1..66f8886 100644 --- a/tests/test_tiled_diffusion_sampling_service.py +++ b/tests/test_tiled_diffusion_sampling_service.py @@ -11,6 +11,7 @@ from typing import Any import pytest import torch +from simple_syrup.domain.conditioning_batch import ConditioningBatch from simple_syrup.services.tiled_diffusion_sampling_service import ( TiledDiffusionSamplingService, ) @@ -158,6 +159,63 @@ def test_service_forwards_differential_diffusion_request( assert calls["differential_diffusion"] is True +def test_service_selects_conditioning_batch_per_latent_item( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Batch conditioning is selected before tiled runtime dispatch.""" + + calls: list[dict[str, Any]] = [] + + def fake_multidiffusion(**kwargs: Any) -> dict[str, Any]: + """Record per-item runtime arguments and return marked samples.""" + + calls.append(kwargs) + return { + "samples": torch.full_like( + kwargs["latent_image"]["samples"], + float(len(calls)), + ) + } + + monkeypatch.setattr( + "simple_syrup.services.tiled_diffusion_sampling_service." + "multidiffusion_sampling.sample_multidiffusion", + fake_multidiffusion, + ) + + latent_samples = torch.zeros((2, 4, 4, 4)) + noise_mask = torch.ones((2, 1, 4, 4)) + result = TiledDiffusionSamplingService().sample( + **( + _sample_kwargs(diffusion_mode="multidiffusion") + | { + "positive": ConditioningBatch(("positive-0", "positive-1")), + "negative": ConditioningBatch(("negative-last",)), + "latent_image": { + "samples": latent_samples, + "batch_index": [7, 11], + "noise_mask": noise_mask, + "downscale_ratio_spacial": 2, + }, + } + ) + ) + + assert len(calls) == 2 + assert calls[0]["positive"] == "positive-0" + assert calls[1]["positive"] == "positive-1" + assert calls[0]["negative"] == "negative-last" + assert calls[1]["negative"] == "negative-last" + assert calls[0]["latent_image"]["batch_index"] == [7] + assert calls[1]["latent_image"]["batch_index"] == [11] + assert torch.equal(calls[0]["latent_image"]["noise_mask"], noise_mask[0:1]) + assert torch.equal(calls[1]["latent_image"]["noise_mask"], noise_mask[1:2]) + assert "downscale_ratio_spacial" not in result + assert result["samples"].shape == latent_samples.shape + assert torch.equal(result["samples"][0], torch.full((4, 4, 4), 1.0)) + assert torch.equal(result["samples"][1], torch.full((4, 4, 4), 2.0)) + + def test_invalid_mode_fails_before_runtime_call( monkeypatch: pytest.MonkeyPatch, ) -> None: From 8e553f5662f9c1a4afd11fe06bb4682d9533ee2e Mon Sep 17 00:00:00 2001 From: Daisy Date: Tue, 26 May 2026 21:48:45 +0000 Subject: [PATCH 10/40] 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)) --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- pyproject.toml | 2 +- simple_syrup/__init__.py | 2 +- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c5c83..4648c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [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)) + # [1.2.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.1.0...v1.2.0) (2026-05-25) diff --git a/package-lock.json b/package-lock.json index 51b5e29..db4a37d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-syrup-comfyui", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-syrup-comfyui", - "version": "1.2.0", + "version": "1.3.0", "license": "AGPL-3.0-or-later", "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/package.json b/package.json index ab32dfc..711725a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-syrup-comfyui", - "version": "1.2.0", + "version": "1.3.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index b81e2b2..48301fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "SimpleSyrup" description = "Workflow-focused ComfyUI extensions for image generation." -version = "1.2.0" +version = "1.3.0" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] requires-python = ">=3.11" diff --git a/simple_syrup/__init__.py b/simple_syrup/__init__.py index 961e224..995b29f 100644 --- a/simple_syrup/__init__.py +++ b/simple_syrup/__init__.py @@ -6,6 +6,6 @@ from __future__ import annotations -__version__ = "1.2.0" +__version__ = "1.3.0" __all__: list[str] = ["__version__"] From ae2dbc7e1a3396e1db1c851be444cf0d4736b3e3 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sat, 30 May 2026 21:50:35 -0400 Subject: [PATCH 11/40] feat(segs): add regional batching and wd14 tagging nodes --- PLAN.md | 499 ++++++++++++++++++ pyproject.toml | 5 + simple_syrup/domain/conditioning_batch.py | 17 + simple_syrup/domain/segs.py | 33 ++ simple_syrup/nodes/__init__.py | 12 + .../nodes/batch_region_conditioning.py | 54 ++ simple_syrup/nodes/batch_segs.py | 44 ++ simple_syrup/nodes/tag_segs_with_wd14.py | 172 ++++++ simple_syrup/nodes/tooltips.py | 28 + simple_syrup/nodes_v3/__init__.py | 9 + .../nodes_v3/batch_region_conditioning.py | 87 +++ simple_syrup/nodes_v3/batch_segs.py | 71 +++ simple_syrup/nodes_v3/tag_segs_with_wd14.py | 136 +++++ .../services/tag_segs_with_wd14_service.py | 173 ++++++ tests/test_batch_region_conditioning_node.py | 35 ++ .../test_batch_region_conditioning_v3_node.py | 50 ++ tests/test_batch_segs_node.py | 53 ++ tests/test_batch_segs_v3_node.py | 89 ++++ tests/test_conditioning_batch.py | 20 + tests/test_node_tooltips.py | 6 + tests/test_registration.py | 42 ++ tests/test_segs_domain.py | 68 +++ tests/test_tag_segs_with_wd14_node.py | 112 ++++ tests/test_tag_segs_with_wd14_service.py | 283 ++++++++++ tests/test_tag_segs_with_wd14_v3_node.py | 103 ++++ tests/test_tiled_sampling_runtime.py | 3 +- 26 files changed, 2203 insertions(+), 1 deletion(-) create mode 100644 PLAN.md create mode 100644 simple_syrup/nodes/batch_region_conditioning.py create mode 100644 simple_syrup/nodes/batch_segs.py create mode 100644 simple_syrup/nodes/tag_segs_with_wd14.py create mode 100644 simple_syrup/nodes_v3/batch_region_conditioning.py create mode 100644 simple_syrup/nodes_v3/batch_segs.py create mode 100644 simple_syrup/nodes_v3/tag_segs_with_wd14.py create mode 100644 simple_syrup/services/tag_segs_with_wd14_service.py create mode 100644 tests/test_batch_region_conditioning_node.py create mode 100644 tests/test_batch_region_conditioning_v3_node.py create mode 100644 tests/test_batch_segs_node.py create mode 100644 tests/test_batch_segs_v3_node.py create mode 100644 tests/test_tag_segs_with_wd14_node.py create mode 100644 tests/test_tag_segs_with_wd14_service.py create mode 100644 tests/test_tag_segs_with_wd14_v3_node.py diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..ae7ba6b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,499 @@ +# Batch SEGS and WD14 SEGS Tagging Plan + +## Goal + +Add a detector-first regional detailing workflow: + +1. Upstream detector nodes create `SEGS` payloads. These can come from multiple Ultralytics detector models, prompted SAM, or any other Impact-compatible SEGS producer. +2. A new **Batch SEGS** node combines those ordered `SEGS` payloads into one ordered `SEGS` payload. +3. A future **Tag SEGS w/ WD14** node runs WD14 over each segment crop and produces a `CONDITIONING_BATCH` aligned to the same segment order. +4. The existing **Detail SEGS as Regions** node receives the batched `SEGS` plus aligned regional conditioning and details all regions in one regional MultiDiffusion pass. + +The immediate implementation target is **Batch SEGS**. The WD14 tagging node is included here so the Batch SEGS design supports the intended downstream workflow. + +## Implementation Status + +- [x] Domain batching policy added in `simple_syrup/domain/segs.py`. + - Landing note: `batch_segs(...)` returns native immutable SEGS and leaves Impact-compatible conversion to Comfy-facing code. +- [x] Domain tests added in `tests/test_segs_domain.py`. + - Landing note: tests cover order-preserving flattening, empty SEGS inputs, all-empty output, no-input rejection, and mismatched headers. +- [x] **Batch SEGS** v3 node added in `simple_syrup/nodes_v3/batch_segs.py`. + - Landing note: the node uses `io.Autogrow.TemplatePrefix` with `segs0` through `segs49`, matching Comfy core batch-node behavior. +- [x] **Batch SEGS** exported through `simple_syrup/nodes_v3/__init__.py`. + - Landing note: the node is intentionally v3-only and is not registered in legacy mappings. +- [x] V3 node and entrypoint tests added or updated. + - Landing note: `tests/test_batch_segs_v3_node.py` covers schema and execution; `tests/test_registration.py` covers v3 entrypoint visibility. +- [x] Focused verification passed. + - Landing note: `..\..\venv\Scripts\python.exe -m pytest -n auto -q tests\test_segs_domain.py tests\test_batch_segs_v3_node.py tests\test_registration.py` passed with 78 tests. +- [x] Format, lint, type check, and full test suite passed for **Batch SEGS**. + - Landing note: initial Batch SEGS gates passed before later workflow scope additions. +- [x] **Tag SEGS w/ WD14** implemented after scope correction. + - Landing note: `TagSEGSWithWD14Service` crops existing SEGS in order, runs WD14, prefixes prompts, CLIP-encodes aligned conditioning, and returns the original SEGS with `CONDITIONING_BATCH`. +- [x] **Tag SEGS w/ WD14** exported through legacy mappings and v3 entrypoint. + - Landing note: unlike **Batch SEGS**, this node is not v3-only because it does not need Autogrow and should be available through every repository-supported export path. +- [x] **Tag SEGS w/ WD14** focused verification passed. + - Landing note: `..\..\venv\Scripts\python.exe -m pytest -n auto -q tests\test_tag_segs_with_wd14_service.py tests\test_tag_segs_with_wd14_node.py tests\test_tag_segs_with_wd14_v3_node.py tests\test_registration.py tests\test_node_tooltips.py` passed with 57 tests. +- [x] Final gates rerun after **Tag SEGS w/ WD14**. + - Landing note: gates passed after both SEGS batching and WD14 tagging were implemented. +- [x] **Batch Region Conditioning** implemented after mixed auto/manual conditioning requirement. + - Landing note: v3-only Autogrow node accepts mixed `CONDITIONING` and `CONDITIONING_BATCH` sockets, flattening existing batches and independent conditionings into one ordered `CONDITIONING_BATCH`. +- [x] Final gates rerun after **Batch Region Conditioning**. + - Landing note: gates passed after the mixed conditioning batcher was added. +- [x] Legacy-visible **Batch SEGS** and **Batch Region Conditioning** added after Comfy menu visibility check. + - Landing note: the Autogrow v3 nodes did not appear in the maintainer's current Comfy menu. Legacy two-input chainable nodes were added and registered so both utilities appear through `NODE_CLASS_MAPPINGS`. +- [x] Final gates rerun after legacy-visible batch nodes. + - Landing note: gates passed after legacy Batch SEGS and Batch Region Conditioning were added. +- [x] Warning handling made explicit before commit. + - Landing note: pytest now treats unhandled warnings as errors, narrowly filters known third-party SWIG import deprecations, and asserts the intentional PyTorch nested tensor warning in the owning test. Final full suite result: 920 passed with no warning summary. + +## Decisions From Maintainer Discussion + +- The node name is **Batch SEGS**. +- **Batch SEGS** must be its own node, not folded into **Tile & Tag SEGS**. +- **Tile & Tag SEGS** remains for generated tile SEGS. The new workflow is for existing detector-produced SEGS. +- **Batch SEGS** must support expandable inputs like ComfyUI core nodes such as **Math Expression**, **Batch Images**, **Batch Masks**, and **Batch Latents**. +- Use Comfy v3 `io.Autogrow` for the expandable input UI. +- It is acceptable for **Batch SEGS** to be v3-only. Do not add an awkward fixed-input legacy fallback. +- **Batch SEGS** must accept existing SEGS batches and flatten them in order. If one input contains segments `1 2 3` and another input contains `4 5 6`, the output must contain `1 2 3 4 5 6`. +- Segment order matters because downstream conditioning is index-aligned to segments. +- All inputs must target the same source image dimensions. Reject mismatched `SEGS` headers. + +## Existing Code Context + +Important files: + +- `simple_syrup/domain/segs.py` + - Owns SEGS coercion, Impact compatibility, sorting, and limiting. + - Existing functions include `coerce_segs`, `coerce_segs_group`, `to_impact_compatible_segs`, and `to_impact_compatible_segs_group`. +- `simple_syrup/nodes_v3/__init__.py` + - Exports v3 nodes through `get_nodes()`. +- `simple_syrup/nodes_v3/tile_and_tag_segs.py` + - Shows the local v3 wrapper pattern. +- `simple_syrup/nodes/__init__.py` + - Legacy mapping exports. **Batch SEGS** does not need to be added here if implemented as v3-only. +- `simple_syrup/services/tile_and_tag_segs_service.py` + - Existing WD14 crop-tag-encode orchestration for generated tile SEGS. +- `simple_syrup/nodes/detail_segs_as_regions.py` + - Existing downstream regional detailer. + - It consumes `SEGS` plus `CONDITIONING_BATCH`. +- `simple_syrup/services/detail_segs_as_regions_service.py` + - Validates and pairs region conditioning by segment index. +- `tests/test_segs_domain.py` + - Existing SEGS domain tests. +- `tests/test_registration.py` + - Existing registration and v3 entrypoint tests. + +Comfy reference examples: + +- `E:\ComfyUI\comfy_extras\nodes_math.py` + - `MathExpressionNode` uses `io.Autogrow.TemplateNames`. +- `E:\ComfyUI\comfy_extras\nodes_post_processing.py` + - `BatchImagesNode`, `BatchMasksNode`, and `BatchLatentsNode` use `io.Autogrow.TemplatePrefix`. +- `E:\ComfyUI\comfy_api\latest\_io.py` + - `Autogrow` implementation and dynamic input expansion. + +## Architecture Constraints + +- Keep Comfy-facing node code thin. +- Put merge policy in the domain layer, not in the node wrapper. +- Domain logic must not import ComfyUI modules. +- Runtime adapters own external system interaction. **Batch SEGS** should not need runtime adapters. +- Preserve public behavior of existing nodes. +- Do not rename existing node IDs, display names, categories, inputs, outputs, or return shapes. +- Do not add internal compatibility shims. +- New or changed code must have docstrings. +- New behavior must be covered by tests. +- Use the ComfyUI virtual environment two directories above the repository for verification. + +## Batch SEGS Functional Specification + +### Node + +Display name: + +```text +Batch SEGS +``` + +Suggested v3 node id: + +```text +SimpleSyrup.BatchSEGS +``` + +Suggested category: + +```text +SimpleSyrup/Detection +``` + +Search aliases: + +```text +batch, merge, join, combine, segs +``` + +Inputs: + +- Expandable Autogrow group named `segs_inputs`. +- Template input type: `SEGS`. +- Prefix: `segs`. +- Minimum visible/required inputs: `2`. +- Maximum inputs: `50`, matching Comfy core batch nodes. + +Outputs: + +- `SEGS` +- Return name: `segs` + +### Behavior + +Given: + +```text +segs0 = ((height, width), [segment1, segment2, segment3]) +segs1 = ((height, width), [segment4, segment5, segment6]) +``` + +Return: + +```text +((height, width), [segment1, segment2, segment3, segment4, segment5, segment6]) +``` + +Rules: + +- Coerce every input using existing SEGS compatibility handling. +- Preserve input order from the Autogrow dict values. +- Preserve order inside each input SEGS payload. +- Reject an empty Autogrow group. This should not happen with `min=2`, but the domain function must still fail clearly if called directly with no inputs. +- Reject headers that do not match exactly. +- Allow empty SEGS payloads. They contribute no segments. +- If every input has no segments, return an empty SEGS payload with the shared header. +- Return an Impact-compatible tuple/list shape. + +Error examples: + +- No inputs: + - `Batch SEGS requires one or more SEGS inputs.` +- Header mismatch: + - `Batch SEGS requires all SEGS inputs to use the same image size; input 2 is 1024x768 but input 1 is 768x1024.` + +Use height-first wording because SEGS headers are `(height, width)`. + +## Batch SEGS Implementation Steps + +### 1. Add Domain Function + +Edit `simple_syrup/domain/segs.py`. + +Status: completed. + +Add a function similar to: + +```python +def batch_segs(values: Iterable[object]) -> NativeSegs: + """Return one SEGS payload containing all segments in input order.""" +``` + +Implementation notes: + +- Convert `values` to a tuple once so emptiness and indexing are deterministic. +- Use `coerce_segs` for each input. +- Track the first header as the expected header. +- Compare every later header to the first header. +- Extend a local `list[Segment]`. +- Return `(expected_header, tuple(segments))`. +- Do not call `to_impact_compatible_segs` in the domain function. Keep the domain return type native. +- Add `batch_segs` to imports where needed. Updating `__all__` is not necessary because this module does not currently define one. + +### 2. Add V3 Node + +Create `simple_syrup/nodes_v3/batch_segs.py`. + +Status: completed. + +Use the existing v3 import pattern from `tile_and_tag_segs.py`: + +- Use `TYPE_CHECKING`. +- Import `comfy_api.latest` lazily through `import_module`. +- Define a `_ComfyNodeBase` type-checking shim. +- Use `_comfy_io.SEGS`. + +Schema sketch: + +```python +class BatchSEGSV3(_ComfyNodeBase): + """Expose expandable SEGS batching through Comfy's v3 API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the Batch SEGS v3 schema.""" + + autogrow_template = _comfy_io.Autogrow.TemplatePrefix( + _comfy_io.SEGS.Input( + "segs", + tooltip="SEGS payload to append to the output batch.", + ), + prefix="segs", + min=2, + max=50, + ) + return _comfy_io.Schema( + node_id="SimpleSyrup.BatchSEGS", + display_name="Batch SEGS", + category="SimpleSyrup/Detection", + description="Combines multiple SEGS inputs into one ordered SEGS payload.", + search_aliases=["batch", "merge", "join", "combine", "segs"], + inputs=[ + _comfy_io.Autogrow.Input( + "segs_inputs", + template=autogrow_template, + tooltip="Expandable SEGS inputs joined in socket order.", + ), + ], + outputs=[ + _comfy_io.SEGS.Output( + "segs", + tooltip="Combined SEGS with all input segments in order.", + ), + ], + ) +``` + +Execute sketch: + +```python +@classmethod +def execute(cls, segs_inputs: Any) -> tuple[object]: + """Batch provided SEGS inputs in Autogrow order.""" + + native = batch_segs(segs_inputs.values()) + return (to_impact_compatible_segs(native),) +``` + +Do not use `io.NodeOutput` unless existing SimpleSyrup v3 wrappers are migrated. Current local wrappers return normal tuples, so match local style. + +### 3. Export V3 Node + +Edit `simple_syrup/nodes_v3/__init__.py`. + +Status: completed. + +- Import `BatchSEGSV3` inside `get_nodes()`. +- Include `BatchSEGSV3` in both returned node lists: + - prompt-control unavailable branch + - prompt-control available branch +- Keep ordering sensible. Put it near `TileAndTagSEGSV3` because both are SEGS workflow utilities. + +Do not edit `simple_syrup/nodes/__init__.py` for this v3-only node. + +Do not edit root `__init__.py` unless tests prove the v3 entrypoint needs changes. It already delegates to `simple_syrup.nodes_v3.get_nodes()`. + +## Batch SEGS Tests + +### Domain Tests + +Add tests to `tests/test_segs_domain.py`. + +Status: completed. + +Required cases: + +1. Batches multiple SEGS payloads in order. + - Input one has labels `1`, `2`, `3`. + - Input two has labels `4`, `5`, `6`. + - Output labels are `1`, `2`, `3`, `4`, `5`, `6`. + +2. Accepts empty SEGS inputs. + - Input one has labels `1`, `2`. + - Input two is empty. + - Input three has label `3`. + - Output labels are `1`, `2`, `3`. + +3. Returns empty output when all inputs are empty. + - Same shared header. + - Output segments tuple is empty. + +4. Rejects no inputs. + - `batch_segs(())` raises `ValueError`. + +5. Rejects mismatched headers. + - First input header `(8, 16)`. + - Second input header `(16, 8)`. + - Error message mentions the mismatched input index and both sizes. + +Use existing `_segment(...)` helper in the test file where possible. + +### V3 Node Tests + +Create `tests/test_batch_segs_v3_node.py`. + +Status: completed. + +Required cases: + +1. Schema test: + - `node_id == "SimpleSyrup.BatchSEGS"` + - `display_name == "Batch SEGS"` + - category is `SimpleSyrup/Detection` + - one input with id `segs_inputs` + - output id is `segs` + - output type is `SEGS` + +2. Execute test: + - Pass a dict like: + ```python + {"segs0": first, "segs1": second} + ``` + - Assert returned labels preserve dict insertion order. + - Assert return shape is Impact-compatible, meaning segments are a list. + +3. Mismatched header test: + - Call `BatchSEGSV3.execute(...)`. + - Assert the domain error is surfaced. + +### Registration Tests + +Update `tests/test_registration.py`. + +Status: completed. + +In both v3 entrypoint tests, update expected node class names to include: + +```text +BatchSEGSV3 +``` + +Expected ordering should match `get_nodes()`. + +No legacy mapping registration test is needed because this node is intentionally v3-only. + +## WD14 SEGS Tagging Node + +Status: completed after scope correction. The original document treated this as a follow-on, but the requested workflow included both **Batch SEGS** and **Tag SEGS w/ WD14**. + +Implemented node name: + +```text +Tag SEGS w/ WD14 +``` + +Inputs: + +- `image`: `IMAGE` +- `segs`: `SEGS` +- `clip`: `CLIP` +- `wd14_tagger`: `WD14_TAGGER` +- `universal_positive`: `STRING` +- `threshold`: `FLOAT` +- `character_threshold`: `FLOAT` +- `replace_underscore`: `BOOLEAN` +- `trailing_comma`: `BOOLEAN` +- `exclude_tags`: `STRING` + +Outputs: + +- `segs`: `SEGS` +- `positive`: `CONDITIONING_BATCH` + +Implemented behavior: + +- Validate the input image is a single image. +- Coerce incoming `SEGS`. +- Validate the `SEGS` header matches image dimensions. +- Crop each segment using `segment.crop_region`. +- Run WD14 over the crops in segment order. +- Prefix each tag prompt with the quality/universal positive prompt. +- Encode the resulting prompts using `ComfyConditioningEncoder.encode_batch`. +- Return the original SEGS unchanged plus the aligned `CONDITIONING_BATCH`. +- Reject mismatched tag count or conditioning count. + +Implementation files: + +- `simple_syrup/services/tag_segs_with_wd14_service.py` +- `simple_syrup/nodes/tag_segs_with_wd14.py` +- `simple_syrup/nodes_v3/tag_segs_with_wd14.py` +- `tests/test_tag_segs_with_wd14_service.py` +- `tests/test_tag_segs_with_wd14_node.py` +- `tests/test_tag_segs_with_wd14_v3_node.py` + +## Batch Region Conditioning Node + +Status: completed after the workflow requirement expanded to mixing autotagged SEGS conditioning with hand-authored regional conditioning. A v3 Autogrow node and a legacy two-input chainable node are both provided. + +Implemented node name: + +```text +Batch Region Conditioning +``` + +Inputs: + +- Expandable v3 Autogrow inputs named `conditioning0`, `conditioning1`, etc. +- Legacy inputs named `first` and `second` for Comfy sessions that show legacy mapping nodes. +- Each input accepts either `CONDITIONING` or `CONDITIONING_BATCH`. +- Minimum visible/required inputs: `2`. +- Maximum inputs: `50`. + +Output: + +- `batch`: `CONDITIONING_BATCH` + +Implemented behavior: + +- Preserve socket order. +- If an input is `CONDITIONING_BATCH`, append all entries in that batch. +- If an input is normal `CONDITIONING`, append it as one entry. +- Return one flattened `CONDITIONING_BATCH`. + +Example: + +```text +auto_positive: [auto1, auto2] +hand_positive: hand1 +extra_positive: [auto3] + ↓ +Batch Region Conditioning + ↓ +[auto1, auto2, hand1, auto3] +``` + +Implementation files: + +- `simple_syrup/domain/conditioning_batch.py` +- `simple_syrup/nodes/batch_region_conditioning.py` +- `simple_syrup/nodes_v3/batch_region_conditioning.py` +- `tests/test_conditioning_batch.py` +- `tests/test_batch_region_conditioning_node.py` +- `tests/test_batch_region_conditioning_v3_node.py` + +## Verification Commands + +Run all commands from repository root: + +```powershell +..\..\venv\Scripts\python.exe -m pytest -n auto -q tests\test_segs_domain.py tests\test_batch_segs_v3_node.py tests\test_registration.py +..\..\venv\Scripts\ruff.exe format . +..\..\venv\Scripts\ruff.exe check . +..\..\venv\Scripts\mypy.exe --strict simple_syrup tests +..\..\venv\Scripts\python.exe -m pytest -n auto -q +``` + +If a required tool is missing from `..\..\venv`, install or update development dependencies in that environment. Do not use global Python or a repository-local virtual environment. + +## Definition of Done + +- [x] `batch_segs` domain behavior exists and is tested. +- [x] **Batch SEGS** v3 node exists and uses `io.Autogrow`. +- [x] **Batch SEGS** is exported through `get_nodes()`. +- [x] Tests prove order-preserving flattening. +- [x] Tests prove mismatched headers fail clearly. +- [x] Tests prove v3 entrypoint includes the node. +- [x] Legacy node mappings are not changed for this v3-only node. +- [x] Required gates pass in the ComfyUI virtual environment. +- [x] **Tag SEGS w/ WD14** service, legacy node, and v3 node exist and are tested. +- [x] **Tag SEGS w/ WD14** is exported through legacy mappings and v3 entrypoint. +- [x] **Tag SEGS w/ WD14** emits `CONDITIONING_BATCH` from `CLIP` plus WD14 tags aligned to SEGS order. +- [x] **Batch Region Conditioning** v3 node accepts mixed `CONDITIONING` and `CONDITIONING_BATCH` inputs. +- [x] **Batch SEGS** and **Batch Region Conditioning** appear through legacy mappings for normal Comfy menu visibility. diff --git a/pyproject.toml b/pyproject.toml index 48301fc..ea19593 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,3 +65,8 @@ ignore_missing_imports = true [tool.pytest.ini_options] pythonpath = [".", "../.."] testpaths = ["tests"] +filterwarnings = [ + "error", + "ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning", + "ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning", +] diff --git a/simple_syrup/domain/conditioning_batch.py b/simple_syrup/domain/conditioning_batch.py index ceb6be4..1b69ba1 100644 --- a/simple_syrup/domain/conditioning_batch.py +++ b/simple_syrup/domain/conditioning_batch.py @@ -40,6 +40,23 @@ class ConditioningBatch: return ConditioningBatch((*self.entries, conditioning)) +def batch_conditioning( + values: tuple[Conditioning | ConditioningBatch, ...], +) -> ConditioningBatch: + """Flatten conditioning values and batches into one ordered batch.""" + + 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 split_prompt_batch(text: str, separator: str = "[SEP]") -> tuple[str, ...]: """Split prompt text into ordered chunks using a configurable separator.""" diff --git a/simple_syrup/domain/segs.py b/simple_syrup/domain/segs.py index 96daa6c..b68158f 100644 --- a/simple_syrup/domain/segs.py +++ b/simple_syrup/domain/segs.py @@ -184,6 +184,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 +310,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.""" diff --git a/simple_syrup/nodes/__init__.py b/simple_syrup/nodes/__init__.py index 7934259..c06329c 100644 --- a/simple_syrup/nodes/__init__.py +++ b/simple_syrup/nodes/__init__.py @@ -7,6 +7,8 @@ from __future__ import annotations from ..runtime.prompt_control_availability import prompt_control_is_available +from .batch_region_conditioning import BatchRegionConditioning +from .batch_segs import BatchSEGS from .conditioning_batch_pack import ConditioningBatchAppend, ConditioningBatchStart from .detail_segs_as_regions import DetailSEGSAsRegions from .detail_segs_by_scale_factor import DetailSEGSByScaleFactor @@ -32,6 +34,7 @@ from .scale_factor import ScaleFactor from .seed import Seed from .simple_load_anima import SimpleLoadAnima from .simple_load_checkpoint import SimpleLoadCheckpoint +from .tag_segs_with_wd14 import TagSEGSWithWD14 from .tile_and_tag_segs import TileAndTagSEGS from .vae_options import VAEDecodeOptions, VAEEncodeOptions from .vitmatte_model_loader import ViTMatteModelLoader @@ -40,6 +43,8 @@ from .wd14_tagger_loader import WD14TaggerLoader _PROMPT_CONTROL_EXPORTS: list[str] = [] NODE_CLASS_MAPPINGS = { + "SimpleSyrup.BatchRegionConditioning": BatchRegionConditioning, + "SimpleSyrup.BatchSEGS": BatchSEGS, "SimpleSyrup.ConditioningBatchAppend": ConditioningBatchAppend, "SimpleSyrup.ConditioningBatchStart": ConditioningBatchStart, "SimpleSyrup.GroundedSAMModelInfo": GroundedSAMModelInfo, @@ -69,12 +74,15 @@ NODE_CLASS_MAPPINGS = { "SimpleSyrup.LoadUltralyticsModel": LoadUltralyticsModel, "SimpleSyrup.DetectSEGSWithUltralytics": DetectSEGSWithUltralytics, "SimpleSyrup.EncodePromptBatch": EncodePromptBatch, + "SimpleSyrup.TagSEGSWithWD14": TagSEGSWithWD14, "SimpleSyrup.TileAndTagSEGS": TileAndTagSEGS, "SimpleSyrup.ViTMatteModelLoader": ViTMatteModelLoader, "SimpleSyrup.WD14TaggerLoader": WD14TaggerLoader, } NODE_DISPLAY_NAME_MAPPINGS = { + "SimpleSyrup.BatchRegionConditioning": "Batch Region Conditioning", + "SimpleSyrup.BatchSEGS": "Batch SEGS", "SimpleSyrup.ConditioningBatchAppend": "Conditioning Batch Append", "SimpleSyrup.ConditioningBatchStart": "Conditioning Batch Start", "SimpleSyrup.GroundedSAMModelInfo": "Grounded SAM Model Info", @@ -106,6 +114,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SimpleSyrup.LoadUltralyticsModel": "Load Ultralytics Model", "SimpleSyrup.DetectSEGSWithUltralytics": "Detect SEGS w/ Ultralytics", "SimpleSyrup.EncodePromptBatch": "Encode Prompt Batch", + "SimpleSyrup.TagSEGSWithWD14": "Tag SEGS w/ WD14", "SimpleSyrup.TileAndTagSEGS": "Tile & Tag SEGS", "SimpleSyrup.ViTMatteModelLoader": "ViTMatte Model Loader", "SimpleSyrup.WD14TaggerLoader": "Load WD14 Tagger", @@ -125,6 +134,8 @@ if prompt_control_is_available(): _PROMPT_CONTROL_EXPORTS.append("ScheduleAndEncodePromptsWithPromptControl") __all__ = [ + "BatchRegionConditioning", + "BatchSEGS", "ConditioningBatchAppend", "ConditioningBatchStart", "GroundedSAMModelInfo", @@ -152,6 +163,7 @@ __all__ = [ "SimpleLoadAnima", "SimpleLoadCheckpoint", "SimpleVAEEncode", + "TagSEGSWithWD14", "TileAndTagSEGS", "UpscaleLatentFromImage", "VAEDecodeOptions", diff --git a/simple_syrup/nodes/batch_region_conditioning.py b/simple_syrup/nodes/batch_region_conditioning.py new file mode 100644 index 0000000..3ffdd27 --- /dev/null +++ b/simple_syrup/nodes/batch_region_conditioning.py @@ -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)),) diff --git a/simple_syrup/nodes/batch_segs.py b/simple_syrup/nodes/batch_segs.py new file mode 100644 index 0000000..f0d56e6 --- /dev/null +++ b/simple_syrup/nodes/batch_segs.py @@ -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),) diff --git a/simple_syrup/nodes/tag_segs_with_wd14.py b/simple_syrup/nodes/tag_segs_with_wd14.py new file mode 100644 index 0000000..d4e9e8a --- /dev/null +++ b/simple_syrup/nodes/tag_segs_with_wd14.py @@ -0,0 +1,172 @@ +# 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 tagging existing SEGS with WD14.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, ClassVar + +from ..nodes import tooltips +from ..runtime.wd14_tagger import WD14TagFormattingControls +from ..services.tag_segs_with_wd14_service import TagSEGSWithWD14Service +from .tile_and_tag_segs import DEFAULT_EXCLUDE_TAGS + + +class TagSEGSWithWD14: + """Create WD14 conditioning for existing SEGS.""" + + RETURN_TYPES = ("SEGS", "CONDITIONING_BATCH") + RETURN_NAMES = ("segs", "positive") + OUTPUT_TOOLTIPS = ( + tooltips.TAG_SEGS_SEGS_OUTPUT, + tooltips.TAG_SEGS_POSITIVE_OUTPUT, + ) + FUNCTION = "tag" + CATEGORY = "SimpleSyrup/Detailing" + DESCRIPTION = ( + "Tags existing SEGS crops with a connected WD14 tagger and returns " + "aligned conditioning for SEGS detailing." + ) + SEARCH_ALIASES = ["tag", "wd14", "segs", "detail", "regional"] + + service_class: ClassVar[Callable[[], TagSEGSWithWD14Service]] = ( + TagSEGSWithWD14Service + ) + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare ComfyUI inputs for WD14 tagging of existing SEGS.""" + + return { + "required": { + "image": ("IMAGE", {"tooltip": tooltips.TAG_SEGS_IMAGE}), + "segs": ("SEGS", {"tooltip": tooltips.TAG_SEGS_SEGS}), + "clip": ("CLIP", {"tooltip": tooltips.TAG_SEGS_CLIP}), + "wd14_tagger": ( + "WD14_TAGGER", + {"tooltip": tooltips.TAG_SEGS_WD14_TAGGER}, + ), + "universal_positive": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": tooltips.TAG_SEGS_UNIVERSAL_POSITIVE, + }, + ), + "threshold": ( + "FLOAT", + { + "default": 0.35, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "tooltip": tooltips.TILE_THRESHOLD, + }, + ), + "character_threshold": ( + "FLOAT", + { + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "tooltip": tooltips.TILE_CHARACTER_THRESHOLD, + }, + ), + "replace_underscore": ( + "BOOLEAN", + { + "default": True, + "tooltip": tooltips.TILE_REPLACE_UNDERSCORE, + }, + ), + "trailing_comma": ( + "BOOLEAN", + { + "default": False, + "tooltip": tooltips.TILE_TRAILING_COMMA, + }, + ), + "exclude_tags": ( + "STRING", + { + "default": DEFAULT_EXCLUDE_TAGS, + "multiline": False, + "tooltip": tooltips.TILE_EXCLUDE_TAGS, + }, + ), + }, + } + + def tag( + self, + image: object, + segs: object, + clip: Any, + wd14_tagger: object, + universal_positive: object, + threshold: object, + character_threshold: object, + replace_underscore: object, + trailing_comma: object, + exclude_tags: object, + ) -> tuple[object, object]: + """Tag existing SEGS and return aligned conditioning.""" + + tag_controls = WD14TagFormattingControls( + threshold=_float_input(threshold, "threshold"), + character_threshold=_float_input( + character_threshold, + "character_threshold", + ), + replace_underscore=_bool_input( + replace_underscore, + "replace_underscore", + ), + trailing_comma=_bool_input(trailing_comma, "trailing_comma"), + exclude_tags=_str_input(exclude_tags, "exclude_tags"), + ) + result = ( + type(self) + .service_class() + .tag( + image=image, + segs=segs, + clip=clip, + wd14_tagger=wd14_tagger, + tag_controls=tag_controls, + universal_positive=_str_input( + universal_positive, + "universal_positive", + ), + ) + ) + return result.segs, result.positive + + +def _float_input(value: object, name: str) -> float: + """Return a float node input.""" + + if isinstance(value, (int, float, str)): + return float(value) + raise TypeError(f"Tag SEGS w/ WD14 requires '{name}' to be a float.") + + +def _str_input(value: object, name: str) -> str: + """Return a string node input.""" + + if isinstance(value, str): + return value + raise TypeError(f"Tag SEGS w/ WD14 requires '{name}' to be a string.") + + +def _bool_input(value: object, name: str) -> bool: + """Return a boolean node input.""" + + if isinstance(value, bool): + return value + raise TypeError(f"Tag SEGS w/ WD14 requires '{name}' to be a boolean.") diff --git a/simple_syrup/nodes/tooltips.py b/simple_syrup/nodes/tooltips.py index da145b4..c0b857d 100644 --- a/simple_syrup/nodes/tooltips.py +++ b/simple_syrup/nodes/tooltips.py @@ -209,3 +209,31 @@ TILE_SEGS_OUTPUT = "Generated tile SEGS in the same order as the conditioning ba TILE_POSITIVE_OUTPUT = ( "Positive conditioning from WD14 tile tags, matched to SEGS order." ) + +TAG_SEGS_IMAGE = "Image that the incoming SEGS were detected from." +TAG_SEGS_SEGS = "Existing SEGS to crop, tag, and keep in their current order." +TAG_SEGS_CLIP = "CLIP model used to encode each generated SEGS prompt." +TAG_SEGS_WD14_TAGGER = "WD14 tagger that reads each SEG crop and suggests prompt tags." +TAG_SEGS_UNIVERSAL_POSITIVE = ( + "Positive prompt text added before every generated SEGS tag prompt." +) +TAG_SEGS_SEGS_OUTPUT = ( + "Original SEGS returned in the same order as the conditioning batch." +) +TAG_SEGS_POSITIVE_OUTPUT = ( + "Positive conditioning from WD14 SEGS tags, matched to SEGS order." +) + +BATCH_SEGS_FIRST = "First SEGS payload in the output order." +BATCH_SEGS_SECOND = "Second SEGS payload appended after the first." +BATCH_SEGS_OUTPUT = "Combined SEGS with all input segments in order." + +BATCH_REGION_CONDITIONING_FIRST = ( + "First conditioning or conditioning batch in the output order." +) +BATCH_REGION_CONDITIONING_SECOND = ( + "Second conditioning or conditioning batch appended after the first." +) +BATCH_REGION_CONDITIONING_OUTPUT = ( + "Conditioning batch containing all input entries in order." +) diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index efafa0f..00cdf91 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -12,8 +12,11 @@ from ..runtime.prompt_control_availability import prompt_control_is_available def get_nodes() -> list[type[object]]: """Return v3 nodes that can be advertised in this environment.""" + from .batch_region_conditioning import BatchRegionConditioningV3 + from .batch_segs import BatchSEGSV3 from .scale_factor import ScaleFactorV3 from .simple_load_checkpoint import SimpleLoadCheckpointV3 + from .tag_segs_with_wd14 import TagSEGSWithWD14V3 from .tile_and_tag_segs import TileAndTagSEGSV3 from .vae_decode_options import VAEDecodeOptionsV3 from .vae_encode_options import VAEEncodeOptionsV3 @@ -22,6 +25,9 @@ def get_nodes() -> list[type[object]]: if not prompt_control_is_available(): return [ WD14TaggerLoaderV3, + BatchSEGSV3, + BatchRegionConditioningV3, + TagSEGSWithWD14V3, TileAndTagSEGSV3, SimpleLoadCheckpointV3, ScaleFactorV3, @@ -38,6 +44,9 @@ def get_nodes() -> list[type[object]]: return [ WD14TaggerLoaderV3, + BatchSEGSV3, + BatchRegionConditioningV3, + TagSEGSWithWD14V3, TileAndTagSEGSV3, SimpleLoadCheckpointV3, ScaleFactorV3, diff --git a/simple_syrup/nodes_v3/batch_region_conditioning.py b/simple_syrup/nodes_v3/batch_region_conditioning.py new file mode 100644 index 0000000..749d6e1 --- /dev/null +++ b/simple_syrup/nodes_v3/batch_region_conditioning.py @@ -0,0 +1,87 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node wrapper for batching regional conditioning.""" + +from __future__ import annotations + +from collections.abc import Mapping +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..domain.conditioning_batch import batch_conditioning + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io +ConditioningBatchIO: Any = ( + None if TYPE_CHECKING else _comfy_io.Custom("CONDITIONING_BATCH") +) + + +class BatchRegionConditioningV3(_ComfyNodeBase): + """Expose expandable mixed conditioning batching through Comfy's v3 API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the Batch Region Conditioning v3 schema.""" + + conditioning_input = _comfy_io.MultiType.Input( + "conditioning", + [_comfy_io.Conditioning, ConditioningBatchIO], + tooltip="Conditioning or conditioning batch to append in socket order.", + ) + autogrow_template = _comfy_io.Autogrow.TemplatePrefix( + conditioning_input, + prefix="conditioning", + min=2, + max=50, + ) + return _comfy_io.Schema( + node_id="SimpleSyrup.BatchRegionConditioning", + display_name="Batch Region Conditioning", + category="SimpleSyrup/Conditioning", + description=( + "Combines CONDITIONING and CONDITIONING_BATCH inputs into one " + "ordered regional conditioning batch." + ), + search_aliases=[ + "batch", + "conditioning batch", + "region conditioning", + "segs prompts", + ], + inputs=[ + _comfy_io.Autogrow.Input( + "conditioning_inputs", + template=autogrow_template, + tooltip=( + "Expandable conditioning inputs flattened in socket order." + ), + ), + ], + outputs=[ + ConditioningBatchIO.Output( + "batch", + tooltip=( + "Conditioning batch containing all input entries in order." + ), + ), + ], + ) + + @classmethod + def execute(cls, conditioning_inputs: Mapping[str, object]) -> tuple[object]: + """Batch conditioning inputs in Autogrow order.""" + + return (batch_conditioning(tuple(conditioning_inputs.values())),) diff --git a/simple_syrup/nodes_v3/batch_segs.py b/simple_syrup/nodes_v3/batch_segs.py new file mode 100644 index 0000000..00f633d --- /dev/null +++ b/simple_syrup/nodes_v3/batch_segs.py @@ -0,0 +1,71 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node wrapper for Batch SEGS.""" + +from __future__ import annotations + +from collections.abc import Mapping +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..domain.segs import batch_segs, to_impact_compatible_segs + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class BatchSEGSV3(_ComfyNodeBase): + """Expose expandable SEGS batching through Comfy's v3 API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the Batch SEGS v3 schema.""" + + autogrow_template = _comfy_io.Autogrow.TemplatePrefix( + _comfy_io.SEGS.Input( + "segs", + tooltip="SEGS payload to append to the output batch.", + ), + prefix="segs", + min=2, + max=50, + ) + return _comfy_io.Schema( + node_id="SimpleSyrup.BatchSEGS", + display_name="Batch SEGS", + category="SimpleSyrup/Detection", + description="Combines multiple SEGS inputs into one ordered SEGS payload.", + search_aliases=["batch", "merge", "join", "combine", "segs"], + inputs=[ + _comfy_io.Autogrow.Input( + "segs_inputs", + template=autogrow_template, + tooltip="Expandable SEGS inputs joined in socket order.", + ), + ], + outputs=[ + _comfy_io.SEGS.Output( + "segs", + tooltip="Combined SEGS with all input segments in order.", + ), + ], + ) + + @classmethod + def execute(cls, segs_inputs: Mapping[str, object]) -> tuple[object]: + """Batch provided SEGS inputs in Autogrow order.""" + + native = batch_segs(segs_inputs.values()) + return (to_impact_compatible_segs(native),) diff --git a/simple_syrup/nodes_v3/tag_segs_with_wd14.py b/simple_syrup/nodes_v3/tag_segs_with_wd14.py new file mode 100644 index 0000000..c1507d8 --- /dev/null +++ b/simple_syrup/nodes_v3/tag_segs_with_wd14.py @@ -0,0 +1,136 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node wrapper for Tag SEGS w/ WD14.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes import tooltips +from ..nodes.tag_segs_with_wd14 import TagSEGSWithWD14 +from ..nodes.tile_and_tag_segs import DEFAULT_EXCLUDE_TAGS + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io +ConditioningBatchIO: Any = ( + None if TYPE_CHECKING else _comfy_io.Custom("CONDITIONING_BATCH") +) +WD14TaggerIO: Any = None if TYPE_CHECKING else _comfy_io.Custom("WD14_TAGGER") + + +class TagSEGSWithWD14V3(_ComfyNodeBase): + """Expose WD14 tagging for existing SEGS through Comfy's v3 API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the Tag SEGS w/ WD14 v3 schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.TagSEGSWithWD14", + display_name="Tag SEGS w/ WD14", + category="SimpleSyrup/Detailing", + description=( + "Tags existing SEGS crops with a connected WD14 tagger and " + "returns aligned conditioning for SEGS detailing." + ), + search_aliases=["tag", "wd14", "segs", "detail", "regional"], + inputs=[ + _comfy_io.Image.Input("image", tooltip=tooltips.TAG_SEGS_IMAGE), + _comfy_io.SEGS.Input("segs", tooltip=tooltips.TAG_SEGS_SEGS), + _comfy_io.Clip.Input("clip", tooltip=tooltips.TAG_SEGS_CLIP), + WD14TaggerIO.Input( + "wd14_tagger", + tooltip=tooltips.TAG_SEGS_WD14_TAGGER, + ), + _comfy_io.String.Input( + "universal_positive", + multiline=False, + default="", + tooltip=tooltips.TAG_SEGS_UNIVERSAL_POSITIVE, + ), + _comfy_io.Float.Input( + "threshold", + default=0.35, + min=0.0, + max=1.0, + step=0.05, + tooltip=tooltips.TILE_THRESHOLD, + ), + _comfy_io.Float.Input( + "character_threshold", + default=1.0, + min=0.0, + max=1.0, + step=0.05, + tooltip=tooltips.TILE_CHARACTER_THRESHOLD, + ), + _comfy_io.Boolean.Input( + "replace_underscore", + default=True, + tooltip=tooltips.TILE_REPLACE_UNDERSCORE, + ), + _comfy_io.Boolean.Input( + "trailing_comma", + default=False, + tooltip=tooltips.TILE_TRAILING_COMMA, + ), + _comfy_io.String.Input( + "exclude_tags", + multiline=False, + default=DEFAULT_EXCLUDE_TAGS, + tooltip=tooltips.TILE_EXCLUDE_TAGS, + ), + ], + outputs=[ + _comfy_io.SEGS.Output( + "segs", + tooltip=tooltips.TAG_SEGS_SEGS_OUTPUT, + ), + ConditioningBatchIO.Output( + "positive", + tooltip=tooltips.TAG_SEGS_POSITIVE_OUTPUT, + ), + ], + ) + + @classmethod + def execute( + cls, + image: object, + segs: object, + clip: Any, + wd14_tagger: object, + universal_positive: str, + threshold: float, + character_threshold: float, + replace_underscore: bool, + trailing_comma: bool, + exclude_tags: str, + ) -> tuple[object, object]: + """Run the legacy implementation behind the v3 schema.""" + + return TagSEGSWithWD14().tag( + image=image, + segs=segs, + clip=clip, + wd14_tagger=wd14_tagger, + universal_positive=universal_positive, + threshold=threshold, + character_threshold=character_threshold, + replace_underscore=replace_underscore, + trailing_comma=trailing_comma, + exclude_tags=exclude_tags, + ) diff --git a/simple_syrup/services/tag_segs_with_wd14_service.py b/simple_syrup/services/tag_segs_with_wd14_service.py new file mode 100644 index 0000000..a522fa0 --- /dev/null +++ b/simple_syrup/services/tag_segs_with_wd14_service.py @@ -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 + +"""Application service for WD14 tagging of existing SEGS.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Protocol + +import torch + +from ..domain.conditioning_batch import ConditioningBatch +from ..domain.prompt_composition import prefix_prompt +from ..domain.segs import ( + ImpactSegs, + NativeSegs, + Segment, + coerce_segs, + to_impact_compatible_segs, +) +from ..masking.segs_mask_ops import crop_image, validate_single_image +from ..runtime.conditioning_encoding import ComfyConditioningEncoder +from ..runtime.loaded_models import LoadedWD14Tagger, unwrap_wd14_tagger +from ..runtime.progress import ProgressReporter, create_comfy_progress +from ..runtime.wd14_tagger import WD14TagFormattingControls, WD14Tagger +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) +OPERATION = "Tag SEGS w/ WD14" + + +class WD14TaggingBoundary(Protocol): + """Tag ordered image crops.""" + + def tag_images( + self, + loaded_tagger: LoadedWD14Tagger, + images: tuple[torch.Tensor, ...], + controls: WD14TagFormattingControls, + progress: ProgressReporter | None = None, + ) -> tuple[str, ...]: + """Return one tag string per image in input order.""" + + +class ConditioningEncodingBoundary(Protocol): + """Encode ordered prompts into a conditioning batch.""" + + def encode_batch(self, clip: Any, chunks: tuple[str, ...]) -> ConditioningBatch: + """Return conditioning entries in prompt order.""" + + +@dataclass(frozen=True) +class TagSEGSWithWD14Result: + """Return unchanged SEGS and aligned positive conditioning.""" + + segs: ImpactSegs + positive: ConditioningBatch + + +class TagSEGSWithWD14Service: + """Tag provided SEGS crops and encode aligned regional conditioning.""" + + def __init__( + self, + tagger: WD14TaggingBoundary | None = None, + encoder: ConditioningEncodingBoundary | None = None, + progress_factory: Callable[[int], ProgressReporter] | None = None, + ) -> None: + """Create the service with injectable collaborators for tests.""" + + self._tagger = tagger or WD14Tagger() + self._encoder = encoder or ComfyConditioningEncoder() + self._progress_factory = progress_factory or create_comfy_progress + + def tag( + self, + image: object, + segs: object, + clip: Any, + wd14_tagger: object, + tag_controls: WD14TagFormattingControls, + universal_positive: str, + ) -> TagSEGSWithWD14Result: + """Return original SEGS plus WD14-derived conditioning in segment order.""" + + image_tensor = validate_single_image(image, OPERATION) + native_segs = coerce_segs(segs) + self._validate_segs_target_image(native_segs, image_tensor) + _header, segments = native_segs + if not segments: + raise ValueError("No SEGS were provided for Tag SEGS w/ WD14.") + + loaded_tagger = unwrap_wd14_tagger(wd14_tagger) + progress = self._progress_factory(len(segments) + 2) + progress.update(1) + crops = tuple( + crop_image(image_tensor, segment.crop_region) for segment in segments + ) + tags = self._tagger.tag_images( + loaded_tagger, + crops, + tag_controls, + progress=progress, + ) + if len(tags) != len(segments): + raise ValueError( + f"WD14 tagger returned {len(tags)} tag(s) for {len(segments)} SEGS." + ) + + prompts = tuple(prefix_prompt(universal_positive, tag) for tag in tags) + positive = self._encoder.encode_batch(clip, prompts) + progress.update(1) + if len(positive.entries) != len(segments): + raise ValueError( + "Conditioning encoder returned " + f"{len(positive.entries)} entries for {len(segments)} SEGS." + ) + + LOGGER.info( + "Tag SEGS w/ WD14 pass completed", + extra={ + "operation": "tag_segs_with_wd14", + "segment_count": len(segments), + "wd14_model": loaded_tagger.model_id, + "threshold": tag_controls.threshold, + "character_threshold": tag_controls.character_threshold, + "universal_positive_present": bool(universal_positive.strip()), + }, + ) + return TagSEGSWithWD14Result( + segs=to_impact_compatible_segs(native_segs), + positive=positive, + ) + + def _validate_segs_target_image( + self, + segs: NativeSegs, + image: torch.Tensor, + ) -> None: + """Reject SEGS that cannot be cropped from the provided image.""" + + header, segments = segs + image_height = int(image.shape[1]) + image_width = int(image.shape[2]) + if header != (image_height, image_width): + raise ValueError( + f"{OPERATION} requires SEGS header dimensions to match the image: " + f"SEGS is {header[0]}x{header[1]}, image is " + f"{image_height}x{image_width}." + ) + for index, segment in enumerate(segments): + _validate_segment_crop(segment, index, image_height, image_width) + + +def _validate_segment_crop( + segment: Segment, + index: int, + image_height: int, + image_width: int, +) -> None: + """Reject a segment crop region that falls outside the image.""" + + region = segment.crop_region + if region.right <= image_width and region.bottom <= image_height: + return + raise ValueError( + f"{OPERATION} SEG {index} ('{segment.label}') crop_region must fit " + f"inside the image; got ({region.left}, {region.top}, {region.right}, " + f"{region.bottom}) for image {image_height}x{image_width}." + ) diff --git a/tests/test_batch_region_conditioning_node.py b/tests/test_batch_region_conditioning_node.py new file mode 100644 index 0000000..6f6ef8c --- /dev/null +++ b/tests/test_batch_region_conditioning_node.py @@ -0,0 +1,35 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Batch Region Conditioning legacy node.""" + +from __future__ import annotations + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.nodes.batch_region_conditioning import BatchRegionConditioning + + +def test_batch_region_conditioning_contract() -> None: + """Batch Region Conditioning exposes a mixed-input legacy contract.""" + + inputs = BatchRegionConditioning.INPUT_TYPES() + + assert BatchRegionConditioning.RETURN_TYPES == ("CONDITIONING_BATCH",) + assert BatchRegionConditioning.RETURN_NAMES == ("batch",) + assert BatchRegionConditioning.FUNCTION == "batch" + assert BatchRegionConditioning.CATEGORY == "SimpleSyrup/Conditioning" + assert list(inputs["required"]) == ["first", "second"] + assert inputs["required"]["first"][0] == "CONDITIONING,CONDITIONING_BATCH" + assert inputs["required"]["second"][0] == "CONDITIONING,CONDITIONING_BATCH" + + +def test_batch_region_conditioning_node_flattens_mixed_inputs() -> None: + """The legacy node flattens batches and normal conditionings in order.""" + + auto = ConditioningBatch(("auto 1", "auto 2")) + hand = "hand 1" + + (batch,) = BatchRegionConditioning().batch(auto, hand) + + assert batch == ConditioningBatch(("auto 1", "auto 2", "hand 1")) diff --git a/tests/test_batch_region_conditioning_v3_node.py b/tests/test_batch_region_conditioning_v3_node.py new file mode 100644 index 0000000..c4c7416 --- /dev/null +++ b/tests/test_batch_region_conditioning_v3_node.py @@ -0,0 +1,50 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Batch Region Conditioning Comfy v3 wrapper.""" + +from __future__ import annotations + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.nodes_v3.batch_region_conditioning import ( + BatchRegionConditioningV3, +) + + +def test_batch_region_conditioning_v3_schema_uses_mixed_autogrow_inputs() -> None: + """The v3 schema accepts conditioning values and conditioning batches.""" + + schema = BatchRegionConditioningV3.define_schema() + + assert schema.node_id == "SimpleSyrup.BatchRegionConditioning" + assert schema.display_name == "Batch Region Conditioning" + assert schema.category == "SimpleSyrup/Conditioning" + assert [input_item.id for input_item in schema.inputs] == ["conditioning_inputs"] + assert schema.inputs[0].io_type == "COMFY_AUTOGROW_V3" + assert schema.inputs[0].template.prefix == "conditioning" + assert schema.inputs[0].template.min == 2 + assert schema.inputs[0].template.max == 50 + assert schema.inputs[0].template.input.get_io_type() == ( + "CONDITIONING,CONDITIONING_BATCH" + ) + assert [output.id for output in schema.outputs] == ["batch"] + assert schema.outputs[0].io_type == "CONDITIONING_BATCH" + + +def test_batch_region_conditioning_v3_execute_flattens_inputs() -> None: + """The v3 wrapper batches mixed inputs in Autogrow insertion order.""" + + auto = ConditioningBatch(("auto 1", "auto 2")) + hand = "hand 1" + extra = ConditioningBatch(("auto 3",)) + + (batch,) = BatchRegionConditioningV3.execute( + { + "conditioning0": auto, + "conditioning1": hand, + "conditioning2": extra, + } + ) + + assert batch == ConditioningBatch(("auto 1", "auto 2", "hand 1", "auto 3")) diff --git a/tests/test_batch_segs_node.py b/tests/test_batch_segs_node.py new file mode 100644 index 0000000..eb25a1a --- /dev/null +++ b/tests/test_batch_segs_node.py @@ -0,0 +1,53 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Batch SEGS legacy node.""" + +from __future__ import annotations + +from typing import cast + +from simple_syrup.domain.segs import BoundingBox, CropRegion, ImpactSegs, Segment +from simple_syrup.nodes.batch_segs import BatchSEGS + + +def test_batch_segs_contract() -> None: + """Batch SEGS exposes a legacy two-input chainable contract.""" + + inputs = BatchSEGS.INPUT_TYPES() + + assert BatchSEGS.RETURN_TYPES == ("SEGS",) + assert BatchSEGS.RETURN_NAMES == ("segs",) + assert BatchSEGS.FUNCTION == "batch" + assert BatchSEGS.CATEGORY == "SimpleSyrup/Detection" + assert list(inputs["required"]) == ["first", "second"] + assert inputs["required"]["first"][0] == "SEGS" + assert inputs["required"]["second"][0] == "SEGS" + + +def test_batch_segs_node_batches_in_input_order() -> None: + """The legacy node returns Impact-compatible batched SEGS.""" + + first = ((8, 8), [_segment("1"), _segment("2")]) + second = ((8, 8), [_segment("3")]) + + (raw_segs,) = BatchSEGS().batch(first, second) + segs = cast(ImpactSegs, raw_segs) + + _header, segments = segs + assert isinstance(segments, list) + assert [segment.label for segment in segments] == ["1", "2", "3"] + + +def _segment(label: str) -> Segment: + """Create a small test segment.""" + + return Segment( + cropped_image=None, + cropped_mask="mask", + confidence=1.0, + crop_region=CropRegion(0, 0, 2, 2), + bbox=BoundingBox(0, 0, 2, 2), + label=label, + ) diff --git a/tests/test_batch_segs_v3_node.py b/tests/test_batch_segs_v3_node.py new file mode 100644 index 0000000..1157c47 --- /dev/null +++ b/tests/test_batch_segs_v3_node.py @@ -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 + +"""Tests for the Batch SEGS Comfy v3 wrapper.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment +from simple_syrup.nodes_v3.batch_segs import BatchSEGSV3 + + +def test_batch_segs_v3_schema_uses_autogrow_segs_inputs() -> None: + """The v3 schema exposes expandable SEGS inputs.""" + + schema = BatchSEGSV3.define_schema() + + assert schema.node_id == "SimpleSyrup.BatchSEGS" + assert schema.display_name == "Batch SEGS" + assert schema.category == "SimpleSyrup/Detection" + assert [input_item.id for input_item in schema.inputs] == ["segs_inputs"] + assert schema.inputs[0].io_type == "COMFY_AUTOGROW_V3" + assert schema.inputs[0].template.prefix == "segs" + assert schema.inputs[0].template.min == 2 + assert schema.inputs[0].template.max == 50 + assert schema.inputs[0].template.input.io_type == "SEGS" + assert [output.id for output in schema.outputs] == ["segs"] + assert schema.outputs[0].io_type == "SEGS" + + +def test_batch_segs_v3_execute_returns_impact_compatible_segs() -> None: + """The v3 wrapper batches SEGS in Autogrow insertion order.""" + + first = ( + (16, 16), + [ + _segment("1", CropRegion(0, 0, 2, 2)), + _segment("2", CropRegion(2, 0, 4, 2)), + _segment("3", CropRegion(4, 0, 6, 2)), + ], + ) + second = ( + (16, 16), + ( + _segment("4", CropRegion(0, 2, 2, 4)), + _segment("5", CropRegion(2, 2, 4, 4)), + _segment("6", CropRegion(4, 2, 6, 4)), + ), + ) + + (raw_segs,) = BatchSEGSV3.execute({"segs0": first, "segs1": second}) + segs = cast(tuple[tuple[int, int], list[Segment]], raw_segs) + + header, segments = segs + assert header == (16, 16) + assert isinstance(segments, list) + assert [segment.label for segment in segments] == ["1", "2", "3", "4", "5", "6"] + + +def test_batch_segs_v3_execute_surfaces_header_mismatch() -> None: + """The v3 wrapper keeps domain validation errors visible.""" + + first = ((8, 16), (_segment("first", CropRegion(0, 0, 2, 2)),)) + second = ((16, 8), (_segment("second", CropRegion(0, 0, 2, 2)),)) + + with pytest.raises(ValueError, match="input 2 is 16x8 but input 1 is 8x16"): + BatchSEGSV3.execute({"segs0": first, "segs1": second}) + + +def _segment(label: str, crop_region: CropRegion) -> Segment: + """Create a segment for Batch SEGS v3 tests.""" + + return Segment( + cropped_image=None, + cropped_mask="mask", + confidence=1.0, + crop_region=crop_region, + bbox=BoundingBox( + crop_region.left, + crop_region.top, + crop_region.right, + crop_region.bottom, + ), + label=label, + ) diff --git a/tests/test_conditioning_batch.py b/tests/test_conditioning_batch.py index 633dba2..0ec9a6a 100644 --- a/tests/test_conditioning_batch.py +++ b/tests/test_conditioning_batch.py @@ -10,6 +10,7 @@ import pytest from simple_syrup.domain.conditioning_batch import ( ConditioningBatch, + batch_conditioning, select_conditioning, split_prompt_batch, ) @@ -65,6 +66,25 @@ def test_conditioning_batch_rejects_negative_indexes() -> None: ConditioningBatch(("a",)).select(-1) +def test_batch_conditioning_flattens_batches_and_normal_conditioning() -> None: + """Mixed conditioning inputs become one ordered per-region batch.""" + + first = ConditioningBatch(("auto 1", "auto 2")) + hand = "hand 1" + second = ConditioningBatch(("auto 3",)) + + batch = batch_conditioning((first, hand, second)) + + assert batch.entries == ("auto 1", "auto 2", "hand 1", "auto 3") + + +def test_batch_conditioning_rejects_no_inputs() -> None: + """At least one input is needed to build a conditioning batch.""" + + with pytest.raises(ValueError, match="one or more inputs"): + batch_conditioning(()) + + def test_select_conditioning_broadcasts_normal_conditioning() -> None: """Normal conditionings pass through unchanged for any valid index.""" diff --git a/tests/test_node_tooltips.py b/tests/test_node_tooltips.py index 003887e..b3797c9 100644 --- a/tests/test_node_tooltips.py +++ b/tests/test_node_tooltips.py @@ -14,6 +14,8 @@ from typing import Any, Protocol, cast import pytest from simple_syrup.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +from simple_syrup.nodes_v3.batch_region_conditioning import BatchRegionConditioningV3 +from simple_syrup.nodes_v3.batch_segs import BatchSEGSV3 from simple_syrup.nodes_v3.encode_prompt_batch_with_prompt_control import ( EncodePromptBatchWithPromptControl, ) @@ -22,6 +24,7 @@ from simple_syrup.nodes_v3.schedule_and_encode_prompts_with_prompt_control impor ScheduleAndEncodePromptsWithPromptControl, ) from simple_syrup.nodes_v3.simple_load_checkpoint import SimpleLoadCheckpointV3 +from simple_syrup.nodes_v3.tag_segs_with_wd14 import TagSEGSWithWD14V3 from simple_syrup.nodes_v3.tile_and_tag_segs import TileAndTagSEGSV3 from simple_syrup.nodes_v3.vae_decode_options import VAEDecodeOptionsV3 from simple_syrup.nodes_v3.vae_encode_options import VAEEncodeOptionsV3 @@ -120,6 +123,9 @@ def test_legacy_named_outputs_provide_tooltips() -> None: [ SimpleLoadCheckpointV3, ScaleFactorV3, + BatchSEGSV3, + BatchRegionConditioningV3, + TagSEGSWithWD14V3, TileAndTagSEGSV3, VAEDecodeOptionsV3, VAEEncodeOptionsV3, diff --git a/tests/test_registration.py b/tests/test_registration.py index 350554f..1ad5a75 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -137,6 +137,29 @@ def test_ksampler_tiled_diffusion_node_is_registered() -> None: ) +def test_batch_segs_node_is_registered() -> None: + """Batch SEGS node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.BatchSEGS"] + + assert registered.__name__ == "BatchSEGS" + assert package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.BatchSEGS"] == "Batch SEGS" + + +def test_batch_region_conditioning_node_is_registered() -> None: + """Batch Region Conditioning node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.BatchRegionConditioning"] + + assert registered.__name__ == "BatchRegionConditioning" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.BatchRegionConditioning"] + == "Batch Region Conditioning" + ) + + def test_latent_diagnostics_node_is_registered() -> None: """Latent Diagnostics node maps to its class and display name.""" @@ -392,6 +415,19 @@ def test_tile_and_tag_segs_node_is_registered() -> None: ) +def test_tag_segs_with_wd14_node_is_registered() -> None: + """Tag SEGS w/ WD14 node maps to its class and display name.""" + + package = importlib.import_module("SimpleSyrup") + registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.TagSEGSWithWD14"] + + assert registered.__name__ == "TagSEGSWithWD14" + assert ( + package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.TagSEGSWithWD14"] + == "Tag SEGS w/ WD14" + ) + + def test_conditioning_batch_nodes_are_registered() -> None: """Conditioning batch nodes map to their classes and display names.""" @@ -548,6 +584,9 @@ def test_v3_entrypoint_registers_tile_and_prompt_control_batch_nodes( assert [node.__name__ for node in nodes] == [ "WD14TaggerLoaderV3", + "BatchSEGSV3", + "BatchRegionConditioningV3", + "TagSEGSWithWD14V3", "TileAndTagSEGSV3", "SimpleLoadCheckpointV3", "ScaleFactorV3", @@ -574,6 +613,9 @@ def test_v3_entrypoint_keeps_tile_node_when_prompt_control_unavailable( assert [node.__name__ for node in nodes] == [ "WD14TaggerLoaderV3", + "BatchSEGSV3", + "BatchRegionConditioningV3", + "TagSEGSWithWD14V3", "TileAndTagSEGSV3", "SimpleLoadCheckpointV3", "ScaleFactorV3", diff --git a/tests/test_segs_domain.py b/tests/test_segs_domain.py index bbfce9f..f073ca5 100644 --- a/tests/test_segs_domain.py +++ b/tests/test_segs_domain.py @@ -16,6 +16,7 @@ from simple_syrup.domain.segs import ( BoundingBox, CropRegion, Segment, + batch_segs, coerce_segment, coerce_segs, coerce_segs_group, @@ -155,6 +156,73 @@ def test_impact_segs_group_conversion_returns_list_outputs() -> None: assert all(isinstance(segments, list) for _header, segments in output) +def test_batch_segs_preserves_input_and_segment_order() -> None: + """Batch SEGS flattens input payloads without reordering segments.""" + + first = ( + (16, 16), + ( + _segment("1", CropRegion(0, 0, 2, 2), 0.9), + _segment("2", CropRegion(2, 0, 4, 2), 0.8), + _segment("3", CropRegion(4, 0, 6, 2), 0.7), + ), + ) + second = ( + (16, 16), + [ + _segment("4", CropRegion(0, 2, 2, 4), 0.6), + _segment("5", CropRegion(2, 2, 4, 4), 0.5), + _segment("6", CropRegion(4, 2, 6, 4), 0.4), + ], + ) + + header, segments = batch_segs((first, second)) + + assert header == (16, 16) + assert [segment.label for segment in segments] == ["1", "2", "3", "4", "5", "6"] + + +def test_batch_segs_allows_empty_payloads() -> None: + """Empty SEGS inputs contribute no segments to the batched payload.""" + + first = ( + (16, 16), + ( + _segment("1", CropRegion(0, 0, 2, 2), 0.9), + _segment("2", CropRegion(2, 0, 4, 2), 0.8), + ), + ) + empty = ((16, 16), ()) + third = ((16, 16), (_segment("3", CropRegion(4, 0, 6, 2), 0.7),)) + + _header, segments = batch_segs((first, empty, third)) + + assert [segment.label for segment in segments] == ["1", "2", "3"] + + +def test_batch_segs_returns_empty_payload_when_all_inputs_are_empty() -> None: + """All-empty SEGS inputs keep the shared header and return no segments.""" + + assert batch_segs((((16, 16), ()), ((16, 16), []))) == ((16, 16), ()) + + +def test_batch_segs_rejects_no_inputs() -> None: + """Batch SEGS requires at least one payload for an output header.""" + + with pytest.raises(ValueError, match="one or more SEGS inputs"): + batch_segs(()) + + +def test_batch_segs_rejects_mismatched_headers() -> None: + """Batch SEGS refuses to merge regions targeting different image sizes.""" + + first = ((8, 16), (_segment("first", CropRegion(0, 0, 2, 2), 0.9),)) + second = ((16, 8), (_segment("second", CropRegion(0, 0, 2, 2), 0.8),)) + + with pytest.raises(ValueError, match="input 2 is 16x8 but input 1 is 8x16"): + batch_segs((first, second)) + + def test_sort_order_options_are_plain_english_and_ordered() -> None: """SEGS sort options match the detector node combo contract.""" diff --git a/tests/test_tag_segs_with_wd14_node.py b/tests/test_tag_segs_with_wd14_node.py new file mode 100644 index 0000000..30bf609 --- /dev/null +++ b/tests/test_tag_segs_with_wd14_node.py @@ -0,0 +1,112 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Tag SEGS w/ WD14 node contract.""" + +from __future__ import annotations + +from typing import Any, cast + +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, ImpactSegs, Segment +from simple_syrup.nodes.tag_segs_with_wd14 import TagSEGSWithWD14 +from simple_syrup.nodes.tile_and_tag_segs import DEFAULT_EXCLUDE_TAGS +from simple_syrup.runtime.wd14_tagger import WD14TagFormattingControls +from simple_syrup.services.tag_segs_with_wd14_service import TagSEGSWithWD14Result + + +def test_tag_segs_with_wd14_contract() -> None: + """Tag SEGS w/ WD14 exposes the agreed ComfyUI contract.""" + + inputs = TagSEGSWithWD14.INPUT_TYPES() + + assert TagSEGSWithWD14.RETURN_TYPES == ("SEGS", "CONDITIONING_BATCH") + assert TagSEGSWithWD14.RETURN_NAMES == ("segs", "positive") + assert TagSEGSWithWD14.FUNCTION == "tag" + assert TagSEGSWithWD14.CATEGORY == "SimpleSyrup/Detailing" + assert list(inputs["required"]) == [ + "image", + "segs", + "clip", + "wd14_tagger", + "universal_positive", + "threshold", + "character_threshold", + "replace_underscore", + "trailing_comma", + "exclude_tags", + ] + assert inputs["required"]["segs"][0] == "SEGS" + assert inputs["required"]["clip"][0] == "CLIP" + assert inputs["required"]["wd14_tagger"][0] == "WD14_TAGGER" + assert inputs["required"]["universal_positive"][0] == "STRING" + assert inputs["required"]["universal_positive"][1]["default"] == "" + assert inputs["required"]["threshold"][1]["default"] == 0.35 + assert inputs["required"]["character_threshold"][1]["default"] == 1.0 + assert inputs["required"]["replace_underscore"][1]["default"] is True + assert inputs["required"]["trailing_comma"][1]["default"] is False + assert inputs["required"]["exclude_tags"][1]["default"] == DEFAULT_EXCLUDE_TAGS + assert "optional" not in inputs + + +def test_tag_segs_with_wd14_delegates_to_service(monkeypatch: Any) -> None: + """The node delegates behavior and returns service outputs unchanged.""" + + service = _FakeService() + monkeypatch.setattr(TagSEGSWithWD14, "service_class", lambda: service) + image = torch.zeros((1, 8, 8, 3)) + segs: ImpactSegs = ((8, 8), []) + wd14_tagger = object() + + output_segs, positive = TagSEGSWithWD14().tag( + image=image, + segs=segs, + clip="clip", + wd14_tagger=wd14_tagger, + universal_positive="masterpiece", + threshold=0.35, + character_threshold=1.0, + replace_underscore=True, + trailing_comma=False, + exclude_tags=DEFAULT_EXCLUDE_TAGS, + ) + + assert output_segs is service.result.segs + assert positive is service.result.positive + assert service.call["image"] is image + assert service.call["segs"] is segs + assert service.call["clip"] == "clip" + assert service.call["wd14_tagger"] is wd14_tagger + assert service.call["universal_positive"] == "masterpiece" + tag_controls = cast(WD14TagFormattingControls, service.call["tag_controls"]) + assert tag_controls.threshold == 0.35 + + +class _FakeService: + """Capture node calls for delegation tests.""" + + def __init__(self) -> None: + """Create a fake service result.""" + + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((8, 8)), + confidence=1.0, + crop_region=CropRegion(0, 0, 8, 8), + bbox=BoundingBox(0, 0, 8, 8), + label="seg_001", + ) + self.result = TagSEGSWithWD14Result( + segs=((8, 8), [segment]), + positive=ConditioningBatch(("encoded",)), + ) + self.call: dict[str, object] = {} + + def tag(self, **kwargs: object) -> TagSEGSWithWD14Result: + """Return a fixed result and remember provided inputs.""" + + self.call = kwargs + return self.result diff --git a/tests/test_tag_segs_with_wd14_service.py b/tests/test_tag_segs_with_wd14_service.py new file mode 100644 index 0000000..24827f7 --- /dev/null +++ b/tests/test_tag_segs_with_wd14_service.py @@ -0,0 +1,283 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for WD14 tagging of existing SEGS.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.runtime.loaded_models import LoadedWD14Tagger +from simple_syrup.runtime.wd14_tagger import ( + FloatArray, + WD14TagFormattingControls, + WD14TagRecord, +) +from simple_syrup.services.tag_segs_with_wd14_service import TagSEGSWithWD14Service + + +def test_service_preserves_existing_segs_tag_and_conditioning_order() -> None: + """Existing SEGS, crops, tags, and conditioning stay aligned by index.""" + + progress = _ProgressRecorder() + tagger = _FakeTagger(("tag first", "", "tag third")) + encoder = _FakeEncoder() + loaded_tagger = _loaded_tagger() + service = TagSEGSWithWD14Service( + tagger=tagger, + encoder=encoder, + progress_factory=lambda _total: progress, + ) + segs = _native_segs(("first", "second", "third")) + + result = service.tag( + image=_image(), + segs=segs, + clip="clip", + wd14_tagger=loaded_tagger, + tag_controls=_tag_controls(), + universal_positive="masterpiece", + ) + + assert [segment.label for segment in result.segs[1]] == [ + "first", + "second", + "third", + ] + assert [tuple(crop.shape) for crop in tagger.crops] == [ + (1, 2, 2, 3), + (1, 2, 2, 3), + (1, 2, 2, 3), + ] + assert tagger.loaded_tagger is loaded_tagger + assert encoder.chunks == ( + "masterpiece, tag first", + "masterpiece", + "masterpiece, tag third", + ) + assert result.positive.entries == ( + "clip:masterpiece, tag first", + "clip:masterpiece", + "clip:masterpiece, tag third", + ) + assert progress.updates == [1, 3, 1] + + +def test_service_rejects_empty_segs() -> None: + """Tagging empty SEGS would not produce a selectable conditioning batch.""" + + service = TagSEGSWithWD14Service( + tagger=_FakeTagger(()), + encoder=_FakeEncoder(), + ) + + with pytest.raises(ValueError, match="No SEGS"): + service.tag( + image=_image(), + segs=((4, 4), ()), + clip="clip", + wd14_tagger=_loaded_tagger(), + tag_controls=_tag_controls(), + universal_positive="", + ) + + +def test_service_rejects_segs_image_header_mismatch() -> None: + """SEGS must describe the image being cropped for tagging.""" + + service = TagSEGSWithWD14Service( + tagger=_FakeTagger(("tag",)), + encoder=_FakeEncoder(), + ) + + with pytest.raises(ValueError, match="SEGS is 8x4, image is 4x4"): + service.tag( + image=_image(), + segs=((8, 4), _native_segs(("first",))[1]), + clip="clip", + wd14_tagger=_loaded_tagger(), + tag_controls=_tag_controls(), + universal_positive="", + ) + + +def test_service_rejects_tagger_count_mismatch() -> None: + """Dropping a tag would break SEGS alignment and is rejected.""" + + service = TagSEGSWithWD14Service( + tagger=_FakeTagger(("only one",)), + encoder=_FakeEncoder(), + ) + + with pytest.raises(ValueError, match="returned 1 tag"): + service.tag( + image=_image(), + segs=_native_segs(("first", "second")), + clip="clip", + wd14_tagger=_loaded_tagger(), + tag_controls=_tag_controls(), + universal_positive="", + ) + + +def test_service_rejects_conditioning_count_mismatch() -> None: + """Dropping encoded conditioning would break SEGS alignment and is rejected.""" + + service = TagSEGSWithWD14Service( + tagger=_FakeTagger(("first", "second")), + encoder=_ShortEncoder(), + ) + + with pytest.raises(ValueError, match="returned 1 entries for 2 SEGS"): + service.tag( + image=_image(), + segs=_native_segs(("first", "second")), + clip="clip", + wd14_tagger=_loaded_tagger(), + tag_controls=_tag_controls(), + universal_positive="", + ) + + +class _FakeTagger: + """Return fixed tag strings for ordered crops.""" + + def __init__(self, tags: tuple[str, ...]) -> None: + """Store the fixed tags.""" + + self.tags = tags + self.crops: tuple[torch.Tensor, ...] = () + self.loaded_tagger: LoadedWD14Tagger | None = None + + def tag_images( + self, + loaded_tagger: LoadedWD14Tagger, + images: tuple[torch.Tensor, ...], + controls: WD14TagFormattingControls, + progress: object | None = None, + ) -> tuple[str, ...]: + """Return fixed tags and remember the crop order.""" + + _ = controls + if progress is not None: + progress.update(len(images)) # type: ignore[attr-defined] + self.loaded_tagger = loaded_tagger + self.crops = images + return self.tags + + +class _FakeEncoder: + """Return visible conditioning values for prompt chunks.""" + + def __init__(self) -> None: + """Initialize captured chunks.""" + + self.chunks: tuple[str, ...] = () + + def encode_batch(self, clip: Any, chunks: tuple[str, ...]) -> ConditioningBatch: + """Encode prompts as simple strings.""" + + self.chunks = chunks + return ConditioningBatch(tuple(f"{clip}:{chunk}" for chunk in chunks)) + + +class _ShortEncoder: + """Return too few conditioning entries for validation tests.""" + + def encode_batch(self, clip: Any, chunks: tuple[str, ...]) -> ConditioningBatch: + """Encode only the first prompt chunk.""" + + _ = clip + return ConditioningBatch((chunks[0],)) + + +class _ProgressRecorder: + """Record service progress updates.""" + + def __init__(self) -> None: + """Initialize captured update values.""" + + self.updates: list[int] = [] + + def update(self, value: int) -> None: + """Record one progress advance.""" + + self.updates.append(value) + + +def _native_segs(labels: tuple[str, ...]) -> NativeSegs: + """Create native SEGS with stable two-pixel crop regions.""" + + segments = tuple( + Segment( + cropped_image=None, + cropped_mask=torch.ones((2, 2)), + confidence=1.0, + crop_region=CropRegion(index, index, index + 2, index + 2), + bbox=BoundingBox(index, index, index + 2, index + 2), + label=label, + ) + for index, label in enumerate(labels) + ) + return (4, 4), segments + + +def _image() -> torch.Tensor: + """Return a small deterministic BHWC image.""" + + return torch.arange(4 * 4 * 3, dtype=torch.float32).reshape(1, 4, 4, 3) / 255.0 + + +def _tag_controls() -> WD14TagFormattingControls: + """Return valid WD14 controls for service tests.""" + + return WD14TagFormattingControls( + threshold=0.35, + character_threshold=1.0, + replace_underscore=True, + trailing_comma=False, + exclude_tags="", + ) + + +def _loaded_tagger() -> LoadedWD14Tagger: + """Return a reusable loaded WD14 tagger test container.""" + + return LoadedWD14Tagger( + model_id="wd-eva02-large-tagger-v3", + source="test", + onnx_path=Path("wd-eva02-large-tagger-v3.onnx"), + csv_path=Path("wd-eva02-large-tagger-v3.csv"), + providers=("CPUExecutionProvider",), + session=_FakeWD14Session(), + tags=(WD14TagRecord("blue_hair", "0"),), + ) + + +class _FakeWD14Session: + """Minimal WD14 session test double.""" + + def get_inputs(self) -> list[object]: + """Return no fake inputs.""" + + return [] + + def get_outputs(self) -> list[object]: + """Return no fake outputs.""" + + return [] + + def run( + self, output_names: list[str], feeds: dict[str, FloatArray] + ) -> list[object]: + """Return no fake outputs.""" + + _ = output_names, feeds + return [] diff --git a/tests/test_tag_segs_with_wd14_v3_node.py b/tests/test_tag_segs_with_wd14_v3_node.py new file mode 100644 index 0000000..b89b9ec --- /dev/null +++ b/tests/test_tag_segs_with_wd14_v3_node.py @@ -0,0 +1,103 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Tag SEGS w/ WD14 Comfy v3 wrapper.""" + +from __future__ import annotations + +from typing import Any + +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, ImpactSegs, Segment +from simple_syrup.nodes.tag_segs_with_wd14 import TagSEGSWithWD14 +from simple_syrup.nodes_v3.tag_segs_with_wd14 import TagSEGSWithWD14V3 +from simple_syrup.services.tag_segs_with_wd14_service import TagSEGSWithWD14Result + + +def test_tag_segs_with_wd14_v3_schema_includes_clip_and_wd14_tagger() -> None: + """The v3 schema exposes existing-SEGS WD14 tagging inputs.""" + + schema = TagSEGSWithWD14V3.define_schema() + + assert schema.node_id == "SimpleSyrup.TagSEGSWithWD14" + assert schema.display_name == "Tag SEGS w/ WD14" + assert [input_item.id for input_item in schema.inputs][:4] == [ + "image", + "segs", + "clip", + "wd14_tagger", + ] + assert schema.inputs[1].io_type == "SEGS" + assert schema.inputs[2].io_type == "CLIP" + assert schema.inputs[3].io_type == "WD14_TAGGER" + universal_positive = schema.inputs[4] + assert universal_positive.io_type == "STRING" + assert universal_positive.default == "" + assert universal_positive.multiline is False + assert [output.id for output in schema.outputs] == ["segs", "positive"] + assert [output.io_type for output in schema.outputs] == [ + "SEGS", + "CONDITIONING_BATCH", + ] + + +def test_tag_segs_with_wd14_v3_execute_forwards_to_legacy_node( + monkeypatch: Any, +) -> None: + """The v3 wrapper forwards execution to the legacy implementation.""" + + service = _FakeService() + monkeypatch.setattr(TagSEGSWithWD14, "service_class", lambda: service) + image = torch.zeros((1, 8, 8, 3)) + segs: ImpactSegs = ((8, 8), []) + wd14_tagger = object() + + output_segs, positive = TagSEGSWithWD14V3.execute( + image=image, + segs=segs, + clip="clip", + wd14_tagger=wd14_tagger, + universal_positive="masterpiece", + threshold=0.35, + character_threshold=1.0, + replace_underscore=True, + trailing_comma=False, + exclude_tags="", + ) + + assert output_segs is service.result.segs + assert positive is service.result.positive + assert service.call["segs"] is segs + assert service.call["clip"] == "clip" + assert service.call["wd14_tagger"] is wd14_tagger + assert service.call["universal_positive"] == "masterpiece" + + +class _FakeService: + """Capture v3 wrapper calls through the legacy node.""" + + def __init__(self) -> None: + """Create a fake service result.""" + + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((8, 8)), + confidence=1.0, + crop_region=CropRegion(0, 0, 8, 8), + bbox=BoundingBox(0, 0, 8, 8), + label="seg_001", + ) + self.result = TagSEGSWithWD14Result( + segs=((8, 8), [segment]), + positive=ConditioningBatch(("encoded",)), + ) + self.call: dict[str, object] = {} + + def tag(self, **kwargs: object) -> TagSEGSWithWD14Result: + """Return a fixed result and remember provided inputs.""" + + self.call = kwargs + return self.result diff --git a/tests/test_tiled_sampling_runtime.py b/tests/test_tiled_sampling_runtime.py index c71082d..c319319 100644 --- a/tests/test_tiled_sampling_runtime.py +++ b/tests/test_tiled_sampling_runtime.py @@ -54,7 +54,8 @@ def test_validate_latent_samples_rejects_non_tensor() -> None: def test_validate_tensor_shape_rejects_nested_tensor() -> None: """Nested tensors are rejected before spatial tiling.""" - samples = torch.nested.nested_tensor([torch.zeros((4, 8, 8))]) + with pytest.warns(UserWarning, match="nested tensors.*prototype stage"): + samples = torch.nested.nested_tensor([torch.zeros((4, 8, 8))]) with pytest.raises(ValueError, match="non-nested latent samples"): tiled_sampling.validate_tensor_shape(samples, sampler_label="TestSampler") From c84578d9f990ca9cfbc112ff870626f57921fb27 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sun, 31 May 2026 14:25:48 -0400 Subject: [PATCH 12/40] refactor(exports): use v3-only node registration --- AGENTS.md | 38 +- PLAN.md | 710 +++++++----------- __init__.py | 8 +- requirements.txt | 1 + simple_syrup/domain/external_llm.py | 153 ++++ simple_syrup/nodes/__init__.py | 171 +---- simple_syrup/nodes/external_llm_prompt.py | 111 +++ simple_syrup/nodes/tooltips.py | 17 + simple_syrup/nodes_v3/__init__.py | 86 ++- simple_syrup/nodes_v3/external_llm_prompt.py | 121 +++ simple_syrup/nodes_v3/legacy_node_wrappers.py | 513 +++++++++++++ simple_syrup/runtime/external_llm_client.py | 241 ++++++ simple_syrup/runtime/external_llm_images.py | 47 ++ simple_syrup/runtime/external_llm_keyring.py | 102 +++ simple_syrup/runtime/external_llm_routes.py | 196 +++++ simple_syrup/runtime/settings.py | 90 ++- simple_syrup/runtime/settings_routes.py | 14 +- .../services/external_llm_prompt_service.py | 296 ++++++++ tests/test_external_llm_client.py | 264 +++++++ tests/test_external_llm_images.py | 25 + tests/test_external_llm_keyring.py | 98 +++ tests/test_external_llm_prompt_node.py | 102 +++ tests/test_external_llm_prompt_service.py | 423 +++++++++++ tests/test_external_llm_prompt_v3_node.py | 81 ++ tests/test_external_llm_routes.py | 401 ++++++++++ tests/test_legacy_node_v3_adapter.py | 84 +++ tests/test_node_tooltips.py | 208 ++--- tests/test_packaging_metadata.py | 1 + tests/test_registration.py | 625 +++------------ tests/test_settings.py | 75 +- tests/test_settings_routes.py | 59 +- web/dist/simple-syrup.js | 435 ++++++++++- web/src/api.ts | 171 ++++- web/src/main.ts | 2 + web/src/refresh.ts | 42 ++ web/src/settings.ts | 395 +++++++++- web/src/types.ts | 4 +- web/tests/api.test.ts | 121 ++- web/tests/refresh.test.ts | 64 ++ web/tests/settings.test.ts | 272 ++++++- web/tests/testUtils.ts | 9 +- 41 files changed, 5526 insertions(+), 1350 deletions(-) create mode 100644 simple_syrup/domain/external_llm.py create mode 100644 simple_syrup/nodes/external_llm_prompt.py create mode 100644 simple_syrup/nodes_v3/external_llm_prompt.py create mode 100644 simple_syrup/nodes_v3/legacy_node_wrappers.py create mode 100644 simple_syrup/runtime/external_llm_client.py create mode 100644 simple_syrup/runtime/external_llm_images.py create mode 100644 simple_syrup/runtime/external_llm_keyring.py create mode 100644 simple_syrup/runtime/external_llm_routes.py create mode 100644 simple_syrup/services/external_llm_prompt_service.py create mode 100644 tests/test_external_llm_client.py create mode 100644 tests/test_external_llm_images.py create mode 100644 tests/test_external_llm_keyring.py create mode 100644 tests/test_external_llm_prompt_node.py create mode 100644 tests/test_external_llm_prompt_service.py create mode 100644 tests/test_external_llm_prompt_v3_node.py create mode 100644 tests/test_external_llm_routes.py create mode 100644 tests/test_legacy_node_v3_adapter.py create mode 100644 web/src/refresh.ts create mode 100644 web/tests/refresh.test.ts diff --git a/AGENTS.md b/AGENTS.md index e182125..357565b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,8 +61,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 +83,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. @@ -99,7 +99,7 @@ If a required tool is missing from `..\..\venv`, install or update development d - 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 +121,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 +213,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. @@ -301,7 +299,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 diff --git a/PLAN.md b/PLAN.md index ae7ba6b..9103302 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,499 +1,365 @@ -# Batch SEGS and WD14 SEGS Tagging Plan +# Clean V3-Only Node Export Plan ## Goal -Add a detector-first regional detailing workflow: +Move SimpleSyrup to a clean Comfy v3-only node export model while preserving the current SimpleSyrup node set through v3 schemas. -1. Upstream detector nodes create `SEGS` payloads. These can come from multiple Ultralytics detector models, prompted SAM, or any other Impact-compatible SEGS producer. -2. A new **Batch SEGS** node combines those ordered `SEGS` payloads into one ordered `SEGS` payload. -3. A future **Tag SEGS w/ WD14** node runs WD14 over each segment crop and produces a `CONDITIONING_BATCH` aligned to the same segment order. -4. The existing **Detail SEGS as Regions** node receives the batched `SEGS` plus aligned regional conditioning and details all regions in one regional MultiDiffusion pass. +After this work is complete: -The immediate implementation target is **Batch SEGS**. The WD14 tagging node is included here so the Batch SEGS design supports the intended downstream workflow. +- `comfy_entrypoint()` is the only supported ComfyUI node registration surface. +- `simple_syrup/nodes_v3/__init__.py::get_nodes()` is the authoritative node list. +- The root package no longer exposes `NODE_CLASS_MAPPINGS` or `NODE_DISPLAY_NAME_MAPPINGS`. +- Legacy Comfy mapping exports are removed instead of maintained as a migration path. +- Every SimpleSyrup node that should remain available in ComfyUI is exported as a v3 node. +- Existing workflow-facing node ids such as `SimpleSyrup.BatchRegionConditioning` remain stable where the same node continues to exist. + +## Decisions Already Made + +- This is a clean break. +- Older ComfyUI installs that only support legacy `NODE_CLASS_MAPPINGS` are not supported. +- Do not build a migration path, compatibility shim, fallback registry, dual export path, or transitional adapter for legacy Comfy exports. +- Do not keep root-level `NODE_CLASS_MAPPINGS` or `NODE_DISPLAY_NAME_MAPPINGS` for third-party import compatibility. +- Export every maintained node through Comfy v3 only. +- Keep stable node ids for maintained nodes so v3-capable Comfy can resolve the same `SimpleSyrup.*` identifiers. +- Existing `simple_syrup.nodes.*` modules may temporarily remain as implementation modules when a v3 wrapper delegates to them, but they must not own registration. +- Long-term architecture should move behavior out of legacy node classes and into services/domain/runtime modules, with v3 wrappers acting as the only Comfy-facing node API layer. +- Prompt Control-dependent nodes remain conditional, but the conditional export decision lives only in `simple_syrup/nodes_v3/__init__.py`. ## Implementation Status -- [x] Domain batching policy added in `simple_syrup/domain/segs.py`. - - Landing note: `batch_segs(...)` returns native immutable SEGS and leaves Impact-compatible conversion to Comfy-facing code. -- [x] Domain tests added in `tests/test_segs_domain.py`. - - Landing note: tests cover order-preserving flattening, empty SEGS inputs, all-empty output, no-input rejection, and mismatched headers. -- [x] **Batch SEGS** v3 node added in `simple_syrup/nodes_v3/batch_segs.py`. - - Landing note: the node uses `io.Autogrow.TemplatePrefix` with `segs0` through `segs49`, matching Comfy core batch-node behavior. -- [x] **Batch SEGS** exported through `simple_syrup/nodes_v3/__init__.py`. - - Landing note: the node is intentionally v3-only and is not registered in legacy mappings. -- [x] V3 node and entrypoint tests added or updated. - - Landing note: `tests/test_batch_segs_v3_node.py` covers schema and execution; `tests/test_registration.py` covers v3 entrypoint visibility. -- [x] Focused verification passed. - - Landing note: `..\..\venv\Scripts\python.exe -m pytest -n auto -q tests\test_segs_domain.py tests\test_batch_segs_v3_node.py tests\test_registration.py` passed with 78 tests. -- [x] Format, lint, type check, and full test suite passed for **Batch SEGS**. - - Landing note: initial Batch SEGS gates passed before later workflow scope additions. -- [x] **Tag SEGS w/ WD14** implemented after scope correction. - - Landing note: `TagSEGSWithWD14Service` crops existing SEGS in order, runs WD14, prefixes prompts, CLIP-encodes aligned conditioning, and returns the original SEGS with `CONDITIONING_BATCH`. -- [x] **Tag SEGS w/ WD14** exported through legacy mappings and v3 entrypoint. - - Landing note: unlike **Batch SEGS**, this node is not v3-only because it does not need Autogrow and should be available through every repository-supported export path. -- [x] **Tag SEGS w/ WD14** focused verification passed. - - Landing note: `..\..\venv\Scripts\python.exe -m pytest -n auto -q tests\test_tag_segs_with_wd14_service.py tests\test_tag_segs_with_wd14_node.py tests\test_tag_segs_with_wd14_v3_node.py tests\test_registration.py tests\test_node_tooltips.py` passed with 57 tests. -- [x] Final gates rerun after **Tag SEGS w/ WD14**. - - Landing note: gates passed after both SEGS batching and WD14 tagging were implemented. -- [x] **Batch Region Conditioning** implemented after mixed auto/manual conditioning requirement. - - Landing note: v3-only Autogrow node accepts mixed `CONDITIONING` and `CONDITIONING_BATCH` sockets, flattening existing batches and independent conditionings into one ordered `CONDITIONING_BATCH`. -- [x] Final gates rerun after **Batch Region Conditioning**. - - Landing note: gates passed after the mixed conditioning batcher was added. -- [x] Legacy-visible **Batch SEGS** and **Batch Region Conditioning** added after Comfy menu visibility check. - - Landing note: the Autogrow v3 nodes did not appear in the maintainer's current Comfy menu. Legacy two-input chainable nodes were added and registered so both utilities appear through `NODE_CLASS_MAPPINGS`. -- [x] Final gates rerun after legacy-visible batch nodes. - - Landing note: gates passed after legacy Batch SEGS and Batch Region Conditioning were added. -- [x] Warning handling made explicit before commit. - - Landing note: pytest now treats unhandled warnings as errors, narrowly filters known third-party SWIG import deprecations, and asserts the intentional PyTorch nested tensor warning in the owning test. Final full suite result: 920 passed with no warning summary. - -## Decisions From Maintainer Discussion - -- The node name is **Batch SEGS**. -- **Batch SEGS** must be its own node, not folded into **Tile & Tag SEGS**. -- **Tile & Tag SEGS** remains for generated tile SEGS. The new workflow is for existing detector-produced SEGS. -- **Batch SEGS** must support expandable inputs like ComfyUI core nodes such as **Math Expression**, **Batch Images**, **Batch Masks**, and **Batch Latents**. -- Use Comfy v3 `io.Autogrow` for the expandable input UI. -- It is acceptable for **Batch SEGS** to be v3-only. Do not add an awkward fixed-input legacy fallback. -- **Batch SEGS** must accept existing SEGS batches and flatten them in order. If one input contains segments `1 2 3` and another input contains `4 5 6`, the output must contain `1 2 3 4 5 6`. -- Segment order matters because downstream conditioning is index-aligned to segments. -- All inputs must target the same source image dimensions. Reject mismatched `SEGS` headers. - -## Existing Code Context - -Important files: - -- `simple_syrup/domain/segs.py` - - Owns SEGS coercion, Impact compatibility, sorting, and limiting. - - Existing functions include `coerce_segs`, `coerce_segs_group`, `to_impact_compatible_segs`, and `to_impact_compatible_segs_group`. -- `simple_syrup/nodes_v3/__init__.py` - - Exports v3 nodes through `get_nodes()`. -- `simple_syrup/nodes_v3/tile_and_tag_segs.py` - - Shows the local v3 wrapper pattern. -- `simple_syrup/nodes/__init__.py` - - Legacy mapping exports. **Batch SEGS** does not need to be added here if implemented as v3-only. -- `simple_syrup/services/tile_and_tag_segs_service.py` - - Existing WD14 crop-tag-encode orchestration for generated tile SEGS. -- `simple_syrup/nodes/detail_segs_as_regions.py` - - Existing downstream regional detailer. - - It consumes `SEGS` plus `CONDITIONING_BATCH`. -- `simple_syrup/services/detail_segs_as_regions_service.py` - - Validates and pairs region conditioning by segment index. -- `tests/test_segs_domain.py` - - Existing SEGS domain tests. -- `tests/test_registration.py` - - Existing registration and v3 entrypoint tests. - -Comfy reference examples: - -- `E:\ComfyUI\comfy_extras\nodes_math.py` - - `MathExpressionNode` uses `io.Autogrow.TemplateNames`. -- `E:\ComfyUI\comfy_extras\nodes_post_processing.py` - - `BatchImagesNode`, `BatchMasksNode`, and `BatchLatentsNode` use `io.Autogrow.TemplatePrefix`. -- `E:\ComfyUI\comfy_api\latest\_io.py` - - `Autogrow` implementation and dynamic input expansion. - -## Architecture Constraints - -- Keep Comfy-facing node code thin. -- Put merge policy in the domain layer, not in the node wrapper. -- Domain logic must not import ComfyUI modules. -- Runtime adapters own external system interaction. **Batch SEGS** should not need runtime adapters. -- Preserve public behavior of existing nodes. -- Do not rename existing node IDs, display names, categories, inputs, outputs, or return shapes. -- Do not add internal compatibility shims. -- New or changed code must have docstrings. -- New behavior must be covered by tests. -- Use the ComfyUI virtual environment two directories above the repository for verification. - -## Batch SEGS Functional Specification - -### Node - -Display name: - -```text -Batch SEGS -``` - -Suggested v3 node id: - -```text -SimpleSyrup.BatchSEGS -``` - -Suggested category: - -```text -SimpleSyrup/Detection -``` - -Search aliases: - -```text -batch, merge, join, combine, segs -``` +- [x] Phase 1: Characterize current export intent. + - Landing note: compared the legacy mapping registry against `nodes_v3.get_nodes()` and used that inventory to drive the wrapper list below. +- [x] Phase 2: Add missing v3 wrappers. + - Landing note: added explicit v3 wrapper classes for legacy-only nodes in `simple_syrup/nodes_v3/legacy_node_wrappers.py`; they delegate to existing implementation classes without preserving legacy registration. +- [x] Phase 3: Make the v3 registry complete. + - Landing note: `simple_syrup/nodes_v3/__init__.py::get_nodes()` now returns every maintained base node plus Prompt Control nodes only when available. +- [x] Phase 4: Remove legacy root exports. + - Landing note: root `__init__.py` no longer imports, exposes, or exports legacy mapping dictionaries. +- [x] Phase 5: Remove legacy registry authority. + - Landing note: `simple_syrup/nodes/__init__.py` is now a package marker for implementation modules, not a ComfyUI registry. +- [x] Phase 6: Update tests. + - Landing note: registration and tooltip tests now assert the v3-only contract; added adapter coverage for schema conversion and hidden-input delegation. +- [x] Phase 7: Update documentation and guardrails. + - Landing note: `AGENTS.md` now identifies Comfy v3 as the only supported export path and removes dual-export requirements. +- [x] Final verification gates. + - Landing note: `ruff format .`, `ruff check .`, `mypy --strict simple_syrup tests`, and full `pytest -n auto -q` all pass. The remaining production `NODE_CLASS_MAPPINGS` text is a runtime read of ComfyUI's global registry in latent provenance tracing, not a SimpleSyrup registration/export path. + +## Non-Goals + +- Do not support old ComfyUI installations that do not call `comfy_entrypoint()`. +- Do not preserve imports of `SimpleSyrup.NODE_CLASS_MAPPINGS`. +- Do not preserve imports of `SimpleSyrup.NODE_DISPLAY_NAME_MAPPINGS`. +- Do not keep `simple_syrup/nodes/__init__.py` as a registry. +- Do not rename maintained node ids as part of this change. +- Do not use this refactor to change node behavior, defaults, output order, return shapes, categories, or display names unless a v3 schema cannot represent the current contract and the behavior change is explicitly tested. + +## Current Export State + +The repository currently has two export paths: + +- Legacy mappings in `simple_syrup/nodes/__init__.py`. +- Comfy v3 node list in `simple_syrup/nodes_v3/__init__.py`. + +The root `__init__.py` currently imports and exports legacy mappings while also defining `comfy_entrypoint()`. The final state must remove the legacy mapping import/export from the root package. + +## Existing V3 Coverage + +The following nodes already have v3 wrappers or v3 classes and must remain in the v3 registry: + +- `SimpleSyrup.BatchRegionConditioning` +- `SimpleSyrup.BatchSEGS` +- `SimpleSyrup.ExternalLLMPrompt` +- `SimpleSyrup.ScaleFactor` +- `SimpleSyrup.SimpleLoadCheckpoint` +- `SimpleSyrup.TagSEGSWithWD14` +- `SimpleSyrup.TileAndTagSEGS` +- `SimpleSyrup.VAEDecodeOptions` +- `SimpleSyrup.VAEEncodeOptions` +- `SimpleSyrup.WD14TaggerLoader` +- `SimpleSyrup.EncodePromptBatchWithPromptControl`, when Prompt Control is available +- `SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl`, when Prompt Control is available + +Verify this list against the code before implementation. If new v3 wrappers have been added since this plan was written, treat the code as authoritative and update the implementation checklist accordingly. + +## Legacy-Only Nodes To Port + +The following current legacy mapping nodes must receive v3 wrappers before legacy exports are removed: -Inputs: +- `SimpleSyrup.ConditioningBatchStart` +- `SimpleSyrup.ConditioningBatchAppend` +- `SimpleSyrup.GroundedSAMModelInfo` +- `SimpleSyrup.GroundingDINOModelLoader` +- `SimpleSyrup.KSamplerExtras` +- `SimpleSyrup.KSamplerTiledDiffusion` +- `SimpleSyrup.LayerStyleSAMModelsAdapter` +- `SimpleSyrup.LatentDiagnostics` +- `SimpleSyrup.PromptEncodeStyle` +- `SimpleSyrup.PromptEncodeStyleAndNormalization` +- `SimpleSyrup.PromptSEGSWithSAM` +- `SimpleSyrup.SimpleVAEEncode` +- `SimpleSyrup.UpscaleLatentFromImage` +- `SimpleSyrup.ResizeImageToTarget` +- `SimpleSyrup.DetailSEGSAsRegions` +- `SimpleSyrup.DetailSEGSByScaleFactor` +- `SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion` +- `SimpleSyrup.SAMModelLoader` +- `SimpleSyrup.Seed` +- `SimpleSyrup.SimpleLoadAnima` +- `SimpleSyrup.LoadUltralyticsModel` +- `SimpleSyrup.DetectSEGSWithUltralytics` +- `SimpleSyrup.EncodePromptBatch` +- `SimpleSyrup.ViTMatteModelLoader` -- Expandable Autogrow group named `segs_inputs`. -- Template input type: `SEGS`. -- Prefix: `segs`. -- Minimum visible/required inputs: `2`. -- Maximum inputs: `50`, matching Comfy core batch nodes. +Port these as maintained v3 nodes. Do not remove a node from the product by omission. -Outputs: +## Final Architecture -- `SEGS` -- Return name: `segs` +### Root Package -### Behavior - -Given: - -```text -segs0 = ((height, width), [segment1, segment2, segment3]) -segs1 = ((height, width), [segment4, segment5, segment6]) -``` - -Return: - -```text -((height, width), [segment1, segment2, segment3, segment4, segment5, segment6]) -``` - -Rules: - -- Coerce every input using existing SEGS compatibility handling. -- Preserve input order from the Autogrow dict values. -- Preserve order inside each input SEGS payload. -- Reject an empty Autogrow group. This should not happen with `min=2`, but the domain function must still fail clearly if called directly with no inputs. -- Reject headers that do not match exactly. -- Allow empty SEGS payloads. They contribute no segments. -- If every input has no segments, return an empty SEGS payload with the shared header. -- Return an Impact-compatible tuple/list shape. - -Error examples: +File: `__init__.py` -- No inputs: - - `Batch SEGS requires one or more SEGS inputs.` -- Header mismatch: - - `Batch SEGS requires all SEGS inputs to use the same image size; input 2 is 1024x768 but input 1 is 768x1024.` +The root package should: -Use height-first wording because SEGS headers are `(height, width)`. +- Set up the local `simple_syrup` package alias if it is still required. +- Define `WEB_DIRECTORY`. +- Define `comfy_entrypoint()`. +- Register backend routes that are required at import time. +- Export `WEB_DIRECTORY` and `comfy_entrypoint` in `__all__`. -## Batch SEGS Implementation Steps +The root package should not: -### 1. Add Domain Function +- Import `simple_syrup.nodes.NODE_CLASS_MAPPINGS`. +- Import `simple_syrup.nodes.NODE_DISPLAY_NAME_MAPPINGS`. +- Define `NODE_CLASS_MAPPINGS`. +- Define `NODE_DISPLAY_NAME_MAPPINGS`. +- Include either mapping name in `__all__`. -Edit `simple_syrup/domain/segs.py`. +### V3 Registry -Status: completed. +File: `simple_syrup/nodes_v3/__init__.py` -Add a function similar to: +`get_nodes()` should: -```python -def batch_segs(values: Iterable[object]) -> NativeSegs: - """Return one SEGS payload containing all segments in input order.""" -``` +- Import v3 node classes lazily inside the function. +- Return every maintained SimpleSyrup v3 node class. +- Keep Prompt Control-only imports inside the availability branch. +- Keep unrelated nodes exported when Prompt Control is unavailable. +- Be the only registry used by ComfyUI. -Implementation notes: +The module should not: -- Convert `values` to a tuple once so emptiness and indexing are deterministic. -- Use `coerce_segs` for each input. -- Track the first header as the expected header. -- Compare every later header to the first header. -- Extend a local `list[Segment]`. -- Return `(expected_header, tuple(segments))`. -- Do not call `to_impact_compatible_segs` in the domain function. Keep the domain return type native. -- Add `batch_segs` to imports where needed. Updating `__all__` is not necessary because this module does not currently define one. +- Import or depend on legacy mapping dictionaries. +- Mirror any legacy registration structure. +- Use compatibility fallback behavior for legacy ComfyUI. -### 2. Add V3 Node +### V3 Wrappers -Create `simple_syrup/nodes_v3/batch_segs.py`. +Directory: `simple_syrup/nodes_v3/` -Status: completed. +Each v3 wrapper should: -Use the existing v3 import pattern from `tile_and_tag_segs.py`: +- Use the same `node_id` as the maintained legacy node id. +- Use the same display name. +- Use the same category. +- Preserve input names, default values, accepted types, optional inputs, hidden inputs, and widget behavior where v3 supports them. +- Preserve output ids, output types, and return shapes. +- Include concise user-facing descriptions and tooltips. +- Delegate non-trivial behavior to existing services/domain/runtime code. +- Avoid duplicating business logic that already has an authoritative service. +- Narrow `Any` at Comfy boundary points before core logic relies on values. +- Use docstrings for new or changed modules, classes, functions, and methods. -- Use `TYPE_CHECKING`. -- Import `comfy_api.latest` lazily through `import_module`. -- Define a `_ComfyNodeBase` type-checking shim. -- Use `_comfy_io.SEGS`. +Temporary delegation to a legacy node implementation class is acceptable only as an implementation detail during this clean export break. It must not preserve or reintroduce legacy registration. -Schema sketch: +### Legacy Node Package -```python -class BatchSEGSV3(_ComfyNodeBase): - """Expose expandable SEGS batching through Comfy's v3 API.""" +File: `simple_syrup/nodes/__init__.py` - @classmethod - def define_schema(cls) -> Any: - """Declare the Batch SEGS v3 schema.""" +This file must stop being a registry. - autogrow_template = _comfy_io.Autogrow.TemplatePrefix( - _comfy_io.SEGS.Input( - "segs", - tooltip="SEGS payload to append to the output batch.", - ), - prefix="segs", - min=2, - max=50, - ) - return _comfy_io.Schema( - node_id="SimpleSyrup.BatchSEGS", - display_name="Batch SEGS", - category="SimpleSyrup/Detection", - description="Combines multiple SEGS inputs into one ordered SEGS payload.", - search_aliases=["batch", "merge", "join", "combine", "segs"], - inputs=[ - _comfy_io.Autogrow.Input( - "segs_inputs", - template=autogrow_template, - tooltip="Expandable SEGS inputs joined in socket order.", - ), - ], - outputs=[ - _comfy_io.SEGS.Output( - "segs", - tooltip="Combined SEGS with all input segments in order.", - ), - ], - ) -``` +Acceptable end states: -Execute sketch: +- Delete the legacy registry file if no internal imports require `simple_syrup.nodes` package exports. +- Reduce it to a normal package marker with a short module docstring and no node mapping dictionaries. +- Keep selective non-registry imports only if they are required by internal code and do not create an alternate registration surface. -```python -@classmethod -def execute(cls, segs_inputs: Any) -> tuple[object]: - """Batch provided SEGS inputs in Autogrow order.""" +Unacceptable end states: - native = batch_segs(segs_inputs.values()) - return (to_impact_compatible_segs(native),) -``` +- `NODE_CLASS_MAPPINGS` still exists. +- `NODE_DISPLAY_NAME_MAPPINGS` still exists. +- `__all__` advertises legacy node mappings. +- The file remains the source of truth for exported nodes. -Do not use `io.NodeOutput` unless existing SimpleSyrup v3 wrappers are migrated. Current local wrappers return normal tuples, so match local style. +## Implementation Phases -### 3. Export V3 Node +### Phase 1: Characterize Current Export Intent -Edit `simple_syrup/nodes_v3/__init__.py`. +1. Read `simple_syrup/nodes/__init__.py`. +2. Record every legacy node id, class, and display name. +3. Read `simple_syrup/nodes_v3/__init__.py`. +4. Record every existing v3 node id. +5. Build a one-to-one checklist showing which maintained nodes already have v3 wrappers and which need wrappers. +6. Add or update tests that prove the current intended node set before removing legacy exports. -Status: completed. +The checklist in this plan is the starting point, but implementation must verify against the current files because the working tree may have changed. -- Import `BatchSEGSV3` inside `get_nodes()`. -- Include `BatchSEGSV3` in both returned node lists: - - prompt-control unavailable branch - - prompt-control available branch -- Keep ordering sensible. Put it near `TileAndTagSEGSV3` because both are SEGS workflow utilities. +### Phase 2: Add Missing V3 Wrappers -Do not edit `simple_syrup/nodes/__init__.py` for this v3-only node. +For each legacy-only maintained node: -Do not edit root `__init__.py` unless tests prove the v3 entrypoint needs changes. It already delegates to `simple_syrup.nodes_v3.get_nodes()`. +1. Create a v3 wrapper module under `simple_syrup/nodes_v3/`. +2. Implement `define_schema()` using `comfy_api.latest.io`. +3. Implement `execute()` or the correct v3 execution hook for the node. +4. Preserve the node id, display name, category, inputs, outputs, defaults, and return shape. +5. Delegate behavior to the existing service/domain/runtime owner where possible. +6. Add focused tests for the wrapper schema and execution behavior. +7. Add tooltip coverage for visible inputs, optional inputs, hidden inputs when supported, and outputs. -## Batch SEGS Tests +Suggested grouping: -### Domain Tests +- Simple value/helper nodes: `Seed`, `ScaleFactor` parity checks, conditioning batch nodes. +- Model loader and adapter nodes: SAM, GroundingDINO, ViTMatte, Ultralytics, LayerStyle, Anima. +- Image/latent utility nodes: resize, latent diagnostics, VAE provenance nodes. +- Prompt nodes: encode style, style normalization, prompt batch. +- SEGS nodes: prompt SEGS, detect SEGS, detail SEGS variants. +- Sampler nodes: `KSamplerExtras`, `KSamplerTiledDiffusion`. -Add tests to `tests/test_segs_domain.py`. +Sampler and SEGS nodes are higher risk because they touch workflow-facing behavior, hidden inputs, lazy expansion, or runtime side effects. Add characterization tests before changing those wrappers. -Status: completed. +### Phase 3: Make The V3 Registry Complete -Required cases: +1. Update `simple_syrup/nodes_v3/__init__.py`. +2. Import every maintained v3 node lazily inside `get_nodes()`. +3. Return all normal nodes in a deterministic order. +4. Keep Prompt Control-only nodes conditional. +5. Add tests for Prompt Control available and unavailable states. +6. Assert that unavailable Prompt Control removes only Prompt Control-specific nodes and does not remove unrelated v3 nodes. -1. Batches multiple SEGS payloads in order. - - Input one has labels `1`, `2`, `3`. - - Input two has labels `4`, `5`, `6`. - - Output labels are `1`, `2`, `3`, `4`, `5`, `6`. +The deterministic order does not need to match the old legacy dictionary order exactly, but it should be stable and easy to review. -2. Accepts empty SEGS inputs. - - Input one has labels `1`, `2`. - - Input two is empty. - - Input three has label `3`. - - Output labels are `1`, `2`, `3`. +### Phase 4: Remove Legacy Root Exports -3. Returns empty output when all inputs are empty. - - Same shared header. - - Output segments tuple is empty. +1. Edit root `__init__.py`. +2. Remove imports of `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS`. +3. Remove those names from `__all__`. +4. Keep `WEB_DIRECTORY`. +5. Keep route registration. +6. Keep `comfy_entrypoint()`. +7. Add tests that assert the root package has no legacy mapping attributes. +8. Add tests that assert `comfy_entrypoint().get_node_list()` returns every maintained node id. -4. Rejects no inputs. - - `batch_segs(())` raises `ValueError`. +This is the main clean-break step. -5. Rejects mismatched headers. - - First input header `(8, 16)`. - - Second input header `(16, 8)`. - - Error message mentions the mismatched input index and both sizes. +### Phase 5: Remove Legacy Registry Authority -Use existing `_segment(...)` helper in the test file where possible. +1. Edit `simple_syrup/nodes/__init__.py`. +2. Remove `NODE_CLASS_MAPPINGS`. +3. Remove `NODE_DISPLAY_NAME_MAPPINGS`. +4. Remove registry-only imports. +5. Remove registry-only `__all__` entries. +6. Keep the package importable if internal modules still import from `simple_syrup.nodes`. -### V3 Node Tests +Do not delete individual legacy node implementation modules in this phase unless their behavior has been fully moved into services/domain/runtime and all callsites have been updated. -Create `tests/test_batch_segs_v3_node.py`. +### Phase 6: Update Tests -Status: completed. +Update the test suite to match the new contract. -Required cases: +Remove or rewrite tests that assert: -1. Schema test: - - `node_id == "SimpleSyrup.BatchSEGS"` - - `display_name == "Batch SEGS"` - - category is `SimpleSyrup/Detection` - - one input with id `segs_inputs` - - output id is `segs` - - output type is `SEGS` +- Root package exposes `NODE_CLASS_MAPPINGS`. +- Root package exposes `NODE_DISPLAY_NAME_MAPPINGS`. +- Node ids are registered through `simple_syrup.nodes.NODE_CLASS_MAPPINGS`. +- Display names are registered through `simple_syrup.nodes.NODE_DISPLAY_NAME_MAPPINGS`. -2. Execute test: - - Pass a dict like: - ```python - {"segs0": first, "segs1": second} - ``` - - Assert returned labels preserve dict insertion order. - - Assert return shape is Impact-compatible, meaning segments are a list. +Add or update tests that assert: -3. Mismatched header test: - - Call `BatchSEGSV3.execute(...)`. - - Assert the domain error is surfaced. +- Root package exposes `comfy_entrypoint`. +- Root package exposes `WEB_DIRECTORY`. +- Root package does not expose `NODE_CLASS_MAPPINGS`. +- Root package does not expose `NODE_DISPLAY_NAME_MAPPINGS`. +- `comfy_entrypoint().get_node_list()` includes every maintained node id. +- Every v3 schema has a non-empty `description`. +- Every v3 visible input has a tooltip where the Comfy v3 API supports it. +- Every v3 output has a tooltip where the Comfy v3 API supports it. +- Prompt Control conditional nodes appear only when Prompt Control is available. +- Prompt Control unavailable state does not remove unrelated nodes. +- V3 wrapper execution preserves legacy behavior for each ported node. -### Registration Tests +Prefer schema tests for Comfy-facing contracts and service/domain tests for behavior. -Update `tests/test_registration.py`. +### Phase 7: Update Documentation And Guardrails -Status: completed. +1. Update `AGENTS.md` to say v3 is the only export path. +2. Remove language requiring updates to legacy mapping exports. +3. Remove language requiring the root package to expose legacy mappings. +4. Update node registration testing rules to reference v3 schemas and `comfy_entrypoint()`. +5. Update Definition of Done language so new, renamed, or removed nodes are verified through v3 only. +6. Search docs for stale statements that claim legacy and v3 exports are both required. +7. Update only documentation that directly describes current engineering rules or current user-facing behavior. -In both v3 entrypoint tests, update expected node class names to include: +Do not create additional design docs unless explicitly requested. -```text -BatchSEGSV3 -``` +## Testing Requirements -Expected ordering should match `get_nodes()`. +Run focused tests while implementing, then run the full required gates before completion. -No legacy mapping registration test is needed because this node is intentionally v3-only. - -## WD14 SEGS Tagging Node - -Status: completed after scope correction. The original document treated this as a follow-on, but the requested workflow included both **Batch SEGS** and **Tag SEGS w/ WD14**. - -Implemented node name: - -```text -Tag SEGS w/ WD14 -``` - -Inputs: - -- `image`: `IMAGE` -- `segs`: `SEGS` -- `clip`: `CLIP` -- `wd14_tagger`: `WD14_TAGGER` -- `universal_positive`: `STRING` -- `threshold`: `FLOAT` -- `character_threshold`: `FLOAT` -- `replace_underscore`: `BOOLEAN` -- `trailing_comma`: `BOOLEAN` -- `exclude_tags`: `STRING` - -Outputs: - -- `segs`: `SEGS` -- `positive`: `CONDITIONING_BATCH` - -Implemented behavior: - -- Validate the input image is a single image. -- Coerce incoming `SEGS`. -- Validate the `SEGS` header matches image dimensions. -- Crop each segment using `segment.crop_region`. -- Run WD14 over the crops in segment order. -- Prefix each tag prompt with the quality/universal positive prompt. -- Encode the resulting prompts using `ComfyConditioningEncoder.encode_batch`. -- Return the original SEGS unchanged plus the aligned `CONDITIONING_BATCH`. -- Reject mismatched tag count or conditioning count. - -Implementation files: - -- `simple_syrup/services/tag_segs_with_wd14_service.py` -- `simple_syrup/nodes/tag_segs_with_wd14.py` -- `simple_syrup/nodes_v3/tag_segs_with_wd14.py` -- `tests/test_tag_segs_with_wd14_service.py` -- `tests/test_tag_segs_with_wd14_node.py` -- `tests/test_tag_segs_with_wd14_v3_node.py` - -## Batch Region Conditioning Node - -Status: completed after the workflow requirement expanded to mixing autotagged SEGS conditioning with hand-authored regional conditioning. A v3 Autogrow node and a legacy two-input chainable node are both provided. - -Implemented node name: - -```text -Batch Region Conditioning -``` - -Inputs: - -- Expandable v3 Autogrow inputs named `conditioning0`, `conditioning1`, etc. -- Legacy inputs named `first` and `second` for Comfy sessions that show legacy mapping nodes. -- Each input accepts either `CONDITIONING` or `CONDITIONING_BATCH`. -- Minimum visible/required inputs: `2`. -- Maximum inputs: `50`. - -Output: - -- `batch`: `CONDITIONING_BATCH` - -Implemented behavior: - -- Preserve socket order. -- If an input is `CONDITIONING_BATCH`, append all entries in that batch. -- If an input is normal `CONDITIONING`, append it as one entry. -- Return one flattened `CONDITIONING_BATCH`. - -Example: - -```text -auto_positive: [auto1, auto2] -hand_positive: hand1 -extra_positive: [auto3] - ↓ -Batch Region Conditioning - ↓ -[auto1, auto2, hand1, auto3] -``` - -Implementation files: - -- `simple_syrup/domain/conditioning_batch.py` -- `simple_syrup/nodes/batch_region_conditioning.py` -- `simple_syrup/nodes_v3/batch_region_conditioning.py` -- `tests/test_conditioning_batch.py` -- `tests/test_batch_region_conditioning_node.py` -- `tests/test_batch_region_conditioning_v3_node.py` - -## Verification Commands - -Run all commands from repository root: +Required commands from repository root: ```powershell -..\..\venv\Scripts\python.exe -m pytest -n auto -q tests\test_segs_domain.py tests\test_batch_segs_v3_node.py tests\test_registration.py ..\..\venv\Scripts\ruff.exe format . ..\..\venv\Scripts\ruff.exe check . ..\..\venv\Scripts\mypy.exe --strict simple_syrup tests ..\..\venv\Scripts\python.exe -m pytest -n auto -q ``` -If a required tool is missing from `..\..\venv`, install or update development dependencies in that environment. Do not use global Python or a repository-local virtual environment. +If frontend files are touched, also run: -## Definition of Done +```powershell +npm ci +npm run lint:web +npm run typecheck:web +npm run test:web +npm run build:web +``` -- [x] `batch_segs` domain behavior exists and is tested. -- [x] **Batch SEGS** v3 node exists and uses `io.Autogrow`. -- [x] **Batch SEGS** is exported through `get_nodes()`. -- [x] Tests prove order-preserving flattening. -- [x] Tests prove mismatched headers fail clearly. -- [x] Tests prove v3 entrypoint includes the node. -- [x] Legacy node mappings are not changed for this v3-only node. -- [x] Required gates pass in the ComfyUI virtual environment. -- [x] **Tag SEGS w/ WD14** service, legacy node, and v3 node exist and are tested. -- [x] **Tag SEGS w/ WD14** is exported through legacy mappings and v3 entrypoint. -- [x] **Tag SEGS w/ WD14** emits `CONDITIONING_BATCH` from `CLIP` plus WD14 tags aligned to SEGS order. -- [x] **Batch Region Conditioning** v3 node accepts mixed `CONDITIONING` and `CONDITIONING_BATCH` inputs. -- [x] **Batch SEGS** and **Batch Region Conditioning** appear through legacy mappings for normal Comfy menu visibility. +Do not use global Python, global pytest, global ruff, global mypy, or a repository-local `.venv`. + +## Acceptance Criteria + +The work is complete when all of these are true: + +- `SimpleSyrup.NODE_CLASS_MAPPINGS` is gone. +- `SimpleSyrup.NODE_DISPLAY_NAME_MAPPINGS` is gone. +- `simple_syrup.nodes.NODE_CLASS_MAPPINGS` is gone. +- `simple_syrup.nodes.NODE_DISPLAY_NAME_MAPPINGS` is gone. +- `comfy_entrypoint()` is present and returns a Comfy extension. +- `comfy_entrypoint().get_node_list()` returns every maintained non-conditional node. +- Prompt Control conditional nodes are exported only when Prompt Control is available. +- Every maintained current node has a v3 schema. +- V3 node ids match maintained current `SimpleSyrup.*` ids. +- Node display names, categories, inputs, defaults, outputs, and execution behavior are preserved unless explicitly changed and tested. +- Tests no longer depend on legacy mapping exports. +- `AGENTS.md` describes v3-only exports. +- Required Python gates pass. +- Required frontend gates pass if frontend files are touched. + +## Review Checklist + +Before requesting review, verify: + +- Search for `NODE_CLASS_MAPPINGS` returns no production registration path. +- Search for `NODE_DISPLAY_NAME_MAPPINGS` returns no production registration path. +- Search for `simple_syrup.nodes_v3` confirms `get_nodes()` is the sole node registry. +- Root `__all__` includes only supported public exports. +- No compatibility layer was added for old ComfyUI. +- No maintained node disappeared from the v3 node list. +- No Prompt Control import happens when Prompt Control is unavailable. +- Error messages exposed to Comfy users remain actionable. +- Tooltips remain concise and user-facing. + +## Notes For New Contributors + +Comfy v3 nodes are schema-driven. Start by opening an existing wrapper in `simple_syrup/nodes_v3/`, then mirror that pattern for the node you are porting. The wrapper should describe the Comfy-facing contract and delegate real work to existing code. + +When in doubt about behavior, write a characterization test around the current legacy node before creating the v3 wrapper. The clean break removes the old export path, not the maintained node behavior. diff --git a/__init__.py b/__init__.py index 940bbd1..a25cbf2 100644 --- a/__init__.py +++ b/__init__.py @@ -12,9 +12,8 @@ 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.runtime.external_llm_routes import ( # noqa: E402 + register_external_llm_routes, ) from .simple_syrup.runtime.settings_routes import register_settings_routes # noqa: E402 @@ -40,10 +39,9 @@ async def comfy_entrypoint() -> object: register_settings_routes() +register_external_llm_routes() __all__ = [ - "NODE_CLASS_MAPPINGS", - "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY", "comfy_entrypoint", ] diff --git a/requirements.txt b/requirements.txt index d0d1464..00b4d6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ timm>=0.6.13 addict>=2.4.0 yapf>=0.43.0 huggingface-hub>=0.34.0 +keyring>=25.0.0 diff --git a/simple_syrup/domain/external_llm.py b/simple_syrup/domain/external_llm.py new file mode 100644 index 0000000..b730da1 --- /dev/null +++ b/simple_syrup/domain/external_llm.py @@ -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) diff --git a/simple_syrup/nodes/__init__.py b/simple_syrup/nodes/__init__.py index c06329c..20a652a 100644 --- a/simple_syrup/nodes/__init__.py +++ b/simple_syrup/nodes/__init__.py @@ -2,172 +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 ..runtime.prompt_control_availability import prompt_control_is_available -from .batch_region_conditioning import BatchRegionConditioning -from .batch_segs import BatchSEGS -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 .tag_segs_with_wd14 import TagSEGSWithWD14 -from .tile_and_tag_segs import TileAndTagSEGS -from .vae_options import VAEDecodeOptions, VAEEncodeOptions -from .vitmatte_model_loader import ViTMatteModelLoader -from .wd14_tagger_loader import WD14TaggerLoader - -_PROMPT_CONTROL_EXPORTS: list[str] = [] - -NODE_CLASS_MAPPINGS = { - "SimpleSyrup.BatchRegionConditioning": BatchRegionConditioning, - "SimpleSyrup.BatchSEGS": BatchSEGS, - "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.VAEDecodeOptions": VAEDecodeOptions, - "SimpleSyrup.VAEEncodeOptions": VAEEncodeOptions, - "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.TagSEGSWithWD14": TagSEGSWithWD14, - "SimpleSyrup.TileAndTagSEGS": TileAndTagSEGS, - "SimpleSyrup.ViTMatteModelLoader": ViTMatteModelLoader, - "SimpleSyrup.WD14TaggerLoader": WD14TaggerLoader, -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "SimpleSyrup.BatchRegionConditioning": "Batch Region Conditioning", - "SimpleSyrup.BatchSEGS": "Batch SEGS", - "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.VAEDecodeOptions": "VAE Decode (Options)", - "SimpleSyrup.VAEEncodeOptions": "VAE Encode (Options)", - "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.TagSEGSWithWD14": "Tag SEGS w/ WD14", - "SimpleSyrup.TileAndTagSEGS": "Tile & Tag SEGS", - "SimpleSyrup.ViTMatteModelLoader": "ViTMatte Model Loader", - "SimpleSyrup.WD14TaggerLoader": "Load WD14 Tagger", -} - -if prompt_control_is_available(): - from .schedule_and_encode_prompts_with_prompt_control import ( - ScheduleAndEncodePromptsWithPromptControl, - ) - - NODE_CLASS_MAPPINGS["SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl"] = ( - ScheduleAndEncodePromptsWithPromptControl - ) - NODE_DISPLAY_NAME_MAPPINGS[ - "SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl" - ] = "Schedule & Encode Prompts" - _PROMPT_CONTROL_EXPORTS.append("ScheduleAndEncodePromptsWithPromptControl") - -__all__ = [ - "BatchRegionConditioning", - "BatchSEGS", - "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", - *_PROMPT_CONTROL_EXPORTS, - "ScaleFactor", - "Seed", - "SimpleLoadAnima", - "SimpleLoadCheckpoint", - "SimpleVAEEncode", - "TagSEGSWithWD14", - "TileAndTagSEGS", - "UpscaleLatentFromImage", - "VAEDecodeOptions", - "VAEEncodeOptions", - "ViTMatteModelLoader", - "WD14TaggerLoader", -] +ComfyUI registration is v3-only and lives in `simple_syrup.nodes_v3`. +""" diff --git a/simple_syrup/nodes/external_llm_prompt.py b/simple_syrup/nodes/external_llm_prompt.py new file mode 100644 index 0000000..ab6eab2 --- /dev/null +++ b/simple_syrup/nodes/external_llm_prompt.py @@ -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, + ), + ) diff --git a/simple_syrup/nodes/tooltips.py b/simple_syrup/nodes/tooltips.py index c0b857d..f9505a5 100644 --- a/simple_syrup/nodes/tooltips.py +++ b/simple_syrup/nodes/tooltips.py @@ -61,6 +61,23 @@ GROUNDING_DINO_TEXT_ENCODER_INPUT = ( VITMATTE_MODEL_INPUT = "ViTMatte model choice used for mask edge refinement." WD14_MODEL_INPUT = "WD14 tagger model choice used to generate tags from image crops." +EXTERNAL_LLM_MODEL_INPUT = "External model used for the prompt request." +EXTERNAL_LLM_SYSTEM_PROMPT_INPUT = "Instruction text sent as the system message." +EXTERNAL_LLM_USER_PROMPT_INPUT = "Prompt text sent as the user message." +EXTERNAL_LLM_MAX_TOKENS_INPUT = ( + "Maximum number of response tokens the external model may generate. Higher " + "values allow longer replies but can take longer and cost more." +) +EXTERNAL_LLM_REASONING_EFFORT_INPUT = ( + "Reasoning behavior for compatible providers. Default omits provider-specific " + "controls; off sends thinking disabled through chat template options." +) +EXTERNAL_LLM_IMAGE_INPUT = ( + "Optional image sent with the user message for vision-capable external models. " + "When a batch is connected, the first image is used." +) +EXTERNAL_LLM_RESPONSE_OUTPUT = "Assistant response returned by the external model." + SAMPLING_MODEL = "Diffusion model used to denoise the input latent." SAMPLING_SEED = ( "Seed used to create sampling noise. Reusing it with matching settings makes " diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index 00cdf91..9983c25 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -14,6 +14,33 @@ def get_nodes() -> list[type[object]]: from .batch_region_conditioning import BatchRegionConditioningV3 from .batch_segs import BatchSEGSV3 + from .external_llm_prompt import ExternalLLMPromptV3 + from .legacy_node_wrappers import ( + ConditioningBatchAppendV3, + ConditioningBatchStartV3, + DetailSEGSAsRegionsV3, + DetailSEGSByScaleFactorTiledDiffusionV3, + DetailSEGSByScaleFactorV3, + DetectSEGSWithUltralyticsV3, + EncodePromptBatchV3, + GroundedSAMModelInfoV3, + GroundingDINOModelLoaderV3, + KSamplerExtrasV3, + KSamplerTiledDiffusionV3, + LatentDiagnosticsV3, + LayerStyleSAMModelsAdapterV3, + LoadUltralyticsModelV3, + PromptEncodeStyleAndNormalizationV3, + PromptEncodeStyleV3, + PromptSEGSWithSAMV3, + ResizeImageToTargetV3, + SAMModelLoaderV3, + SeedV3, + SimpleLoadAnimaV3, + SimpleVAEEncodeV3, + UpscaleLatentFromImageV3, + ViTMatteModelLoaderV3, + ) from .scale_factor import ScaleFactorV3 from .simple_load_checkpoint import SimpleLoadCheckpointV3 from .tag_segs_with_wd14 import TagSEGSWithWD14V3 @@ -22,18 +49,45 @@ def get_nodes() -> list[type[object]]: from .vae_encode_options import VAEEncodeOptionsV3 from .wd14_tagger_loader import WD14TaggerLoaderV3 + nodes: list[type[object]] = [ + BatchRegionConditioningV3, + BatchSEGSV3, + ConditioningBatchAppendV3, + ConditioningBatchStartV3, + DetailSEGSAsRegionsV3, + DetailSEGSByScaleFactorTiledDiffusionV3, + DetailSEGSByScaleFactorV3, + DetectSEGSWithUltralyticsV3, + EncodePromptBatchV3, + ExternalLLMPromptV3, + GroundedSAMModelInfoV3, + GroundingDINOModelLoaderV3, + KSamplerExtrasV3, + KSamplerTiledDiffusionV3, + LatentDiagnosticsV3, + LayerStyleSAMModelsAdapterV3, + LoadUltralyticsModelV3, + PromptEncodeStyleAndNormalizationV3, + PromptEncodeStyleV3, + PromptSEGSWithSAMV3, + ResizeImageToTargetV3, + SAMModelLoaderV3, + ScaleFactorV3, + SeedV3, + SimpleLoadAnimaV3, + SimpleLoadCheckpointV3, + SimpleVAEEncodeV3, + TagSEGSWithWD14V3, + TileAndTagSEGSV3, + UpscaleLatentFromImageV3, + VAEDecodeOptionsV3, + VAEEncodeOptionsV3, + ViTMatteModelLoaderV3, + WD14TaggerLoaderV3, + ] + if not prompt_control_is_available(): - return [ - WD14TaggerLoaderV3, - BatchSEGSV3, - BatchRegionConditioningV3, - TagSEGSWithWD14V3, - TileAndTagSEGSV3, - SimpleLoadCheckpointV3, - ScaleFactorV3, - VAEDecodeOptionsV3, - VAEEncodeOptionsV3, - ] + return nodes from .encode_prompt_batch_with_prompt_control import ( EncodePromptBatchWithPromptControl, @@ -43,15 +97,7 @@ def get_nodes() -> list[type[object]]: ) return [ - WD14TaggerLoaderV3, - BatchSEGSV3, - BatchRegionConditioningV3, - TagSEGSWithWD14V3, - TileAndTagSEGSV3, - SimpleLoadCheckpointV3, - ScaleFactorV3, - VAEDecodeOptionsV3, - VAEEncodeOptionsV3, + *nodes, EncodePromptBatchWithPromptControl, ScheduleAndEncodePromptsWithPromptControl, ] diff --git a/simple_syrup/nodes_v3/external_llm_prompt.py b/simple_syrup/nodes_v3/external_llm_prompt.py new file mode 100644 index 0000000..ee36145 --- /dev/null +++ b/simple_syrup/nodes_v3/external_llm_prompt.py @@ -0,0 +1,121 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node wrapper for external LLM prompt generation.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..domain.external_llm import ( + DEFAULT_EXTERNAL_LLM_MAX_TOKENS, + DEFAULT_EXTERNAL_LLM_REASONING_EFFORT, + EXTERNAL_LLM_REASONING_EFFORTS, +) +from ..nodes import tooltips +from ..nodes.external_llm_prompt import ( + MAX_EXTERNAL_LLM_MAX_TOKENS, + ExternalLLMPrompt, +) + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class ExternalLLMPromptV3(_ComfyNodeBase): + """Expose External LLM Prompt through Comfy's v3 extension API.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the External LLM Prompt v3 schema.""" + + required = ExternalLLMPrompt.INPUT_TYPES()["required"] + model_choices = list(required["model"][0]) + model_default = str(required["model"][1]["default"]) + return _comfy_io.Schema( + node_id="SimpleSyrup.ExternalLLMPrompt", + display_name="External LLM Prompt", + category="SimpleSyrup/Prompting", + description=( + "Sends system and user prompts to a configured external LLM provider." + ), + search_aliases=["llm", "openai", "prompt", "preprocess", "text"], + inputs=[ + _comfy_io.Combo.Input( + "model", + options=model_choices, + default=model_default, + tooltip=tooltips.EXTERNAL_LLM_MODEL_INPUT, + ), + _comfy_io.String.Input( + "system_prompt", + multiline=False, + default="", + tooltip=tooltips.EXTERNAL_LLM_SYSTEM_PROMPT_INPUT, + ), + _comfy_io.String.Input( + "user_prompt", + multiline=False, + default="", + tooltip=tooltips.EXTERNAL_LLM_USER_PROMPT_INPUT, + ), + _comfy_io.Int.Input( + "max_tokens", + default=DEFAULT_EXTERNAL_LLM_MAX_TOKENS, + min=1, + max=MAX_EXTERNAL_LLM_MAX_TOKENS, + step=1, + tooltip=tooltips.EXTERNAL_LLM_MAX_TOKENS_INPUT, + ), + _comfy_io.Combo.Input( + "reasoning_effort", + options=list(EXTERNAL_LLM_REASONING_EFFORTS), + default=DEFAULT_EXTERNAL_LLM_REASONING_EFFORT, + tooltip=tooltips.EXTERNAL_LLM_REASONING_EFFORT_INPUT, + ), + _comfy_io.Image.Input( + "image", + optional=True, + tooltip=tooltips.EXTERNAL_LLM_IMAGE_INPUT, + ), + ], + outputs=[ + _comfy_io.String.Output( + "response", + tooltip=tooltips.EXTERNAL_LLM_RESPONSE_OUTPUT, + ), + ], + ) + + @classmethod + def execute( + cls, + 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]: + """Run the legacy node implementation behind the v3 schema.""" + + return ExternalLLMPrompt().generate( + model=model, + system_prompt=system_prompt, + user_prompt=user_prompt, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + image=image, + ) diff --git a/simple_syrup/nodes_v3/legacy_node_wrappers.py b/simple_syrup/nodes_v3/legacy_node_wrappers.py new file mode 100644 index 0000000..b99801e --- /dev/null +++ b/simple_syrup/nodes_v3/legacy_node_wrappers.py @@ -0,0 +1,513 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 wrappers for nodes whose behavior still lives in legacy modules.""" + +from __future__ import annotations + +from collections.abc import Mapping +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes.conditioning_batch_pack import ( + ConditioningBatchAppend, + ConditioningBatchStart, +) +from ..nodes.detail_segs_as_regions import DetailSEGSAsRegions +from ..nodes.detail_segs_by_scale_factor import DetailSEGSByScaleFactor +from ..nodes.detail_segs_by_scale_factor_tiled_diffusion import ( + DetailSEGSByScaleFactorTiledDiffusion, +) +from ..nodes.detect_segs_with_ultralytics import DetectSEGSWithUltralytics +from ..nodes.encode_prompt_batch import EncodePromptBatch +from ..nodes.grounded_sam_model_info import GroundedSAMModelInfo +from ..nodes.grounding_dino_model_loader import GroundingDINOModelLoader +from ..nodes.image_resize_to_target import ResizeImageToTarget +from ..nodes.ksampler_extras import KSamplerExtras +from ..nodes.ksampler_tiled_diffusion import KSamplerTiledDiffusion +from ..nodes.latent_diagnostics import LatentDiagnostics +from ..nodes.layerstyle_sam_models_adapter import LayerStyleSAMModelsAdapter +from ..nodes.load_ultralytics_model import LoadUltralyticsModel +from ..nodes.prompt_encode_style import PromptEncodeStyle +from ..nodes.prompt_encode_style_and_normalization import ( + PromptEncodeStyleAndNormalization, +) +from ..nodes.prompt_segs_with_sam import PromptSEGSWithSAM +from ..nodes.provenance_latent import SimpleVAEEncode, UpscaleLatentFromImage +from ..nodes.sam_model_loader import SAMModelLoader +from ..nodes.seed import Seed +from ..nodes.simple_load_anima import SimpleLoadAnima +from ..nodes.vitmatte_model_loader import ViTMatteModelLoader + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + hidden: ClassVar[Any] + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + +_HIDDEN_INPUTS = { + "PROMPT": "prompt", + "DYNPROMPT": "dynprompt", + "EXTRA_PNGINFO": "extra_pnginfo", + "UNIQUE_ID": "unique_id", + "AUTH_TOKEN_COMFY_ORG": "auth_token_comfy_org", + "API_KEY_COMFY_ORG": "api_key_comfy_org", +} + + +class LegacyNodeV3Adapter(_ComfyNodeBase): + """Build a v3 schema and execution bridge for a legacy implementation class.""" + + LEGACY_NODE_CLASS: ClassVar[type[Any]] + NODE_ID: ClassVar[str] + DISPLAY_NAME: ClassVar[str] + ENABLE_EXPAND: ClassVar[bool] = False + + @classmethod + def define_schema(cls) -> Any: + """Declare a v3 schema from the implementation class contract.""" + + legacy = cls.LEGACY_NODE_CLASS + return _comfy_io.Schema( + node_id=cls.NODE_ID, + display_name=cls.DISPLAY_NAME, + category=str(getattr(legacy, "CATEGORY", "SimpleSyrup")), + description=str(getattr(legacy, "DESCRIPTION", "")), + search_aliases=list(getattr(legacy, "SEARCH_ALIASES", [])), + inputs=_v3_inputs(legacy.INPUT_TYPES()), + outputs=_v3_outputs(legacy), + hidden=_v3_hidden_inputs(legacy.INPUT_TYPES()), + is_input_list=bool(getattr(legacy, "INPUT_IS_LIST", False)), + is_output_node=bool(getattr(legacy, "OUTPUT_NODE", False)), + enable_expand=cls.ENABLE_EXPAND, + ) + + @classmethod + def execute(cls, **kwargs: object) -> Any: + """Run the wrapped implementation with v3-provided inputs.""" + + values = dict(kwargs) + for name, hidden_attr in _legacy_hidden_inputs( + cls.LEGACY_NODE_CLASS.INPUT_TYPES() + ).items(): + if name not in values: + values[name] = getattr(cls.hidden, hidden_attr) + + function_name = str(cls.LEGACY_NODE_CLASS.FUNCTION) + implementation = cls.LEGACY_NODE_CLASS() + function = getattr(implementation, function_name) + return function(**values) + + +class ConditioningBatchStartV3(LegacyNodeV3Adapter): + """Expose Conditioning Batch Start through Comfy v3 only.""" + + LEGACY_NODE_CLASS = ConditioningBatchStart + NODE_ID = "SimpleSyrup.ConditioningBatchStart" + DISPLAY_NAME = "Conditioning Batch Start" + + +class ConditioningBatchAppendV3(LegacyNodeV3Adapter): + """Expose Conditioning Batch Append through Comfy v3 only.""" + + LEGACY_NODE_CLASS = ConditioningBatchAppend + NODE_ID = "SimpleSyrup.ConditioningBatchAppend" + DISPLAY_NAME = "Conditioning Batch Append" + + +class GroundedSAMModelInfoV3(LegacyNodeV3Adapter): + """Expose Grounded SAM Model Info through Comfy v3 only.""" + + LEGACY_NODE_CLASS = GroundedSAMModelInfo + NODE_ID = "SimpleSyrup.GroundedSAMModelInfo" + DISPLAY_NAME = "Grounded SAM Model Info" + + +class GroundingDINOModelLoaderV3(LegacyNodeV3Adapter): + """Expose GroundingDINO Model Loader through Comfy v3 only.""" + + LEGACY_NODE_CLASS = GroundingDINOModelLoader + NODE_ID = "SimpleSyrup.GroundingDINOModelLoader" + DISPLAY_NAME = "GroundingDINO Model Loader" + + +class KSamplerExtrasV3(LegacyNodeV3Adapter): + """Expose KSampler Extras through Comfy v3 only.""" + + LEGACY_NODE_CLASS = KSamplerExtras + NODE_ID = "SimpleSyrup.KSamplerExtras" + DISPLAY_NAME = "KSampler (Extras)" + + +class KSamplerTiledDiffusionV3(LegacyNodeV3Adapter): + """Expose KSampler Tiled Diffusion through Comfy v3 only.""" + + LEGACY_NODE_CLASS = KSamplerTiledDiffusion + NODE_ID = "SimpleSyrup.KSamplerTiledDiffusion" + DISPLAY_NAME = "KSampler (Tiled Diffusion)" + + +class LayerStyleSAMModelsAdapterV3(LegacyNodeV3Adapter): + """Expose LayerStyle SAM Models Adapter through Comfy v3 only.""" + + LEGACY_NODE_CLASS = LayerStyleSAMModelsAdapter + NODE_ID = "SimpleSyrup.LayerStyleSAMModelsAdapter" + DISPLAY_NAME = "LayerStyle SAM Models Adapter" + + +class LatentDiagnosticsV3(LegacyNodeV3Adapter): + """Expose Latent Diagnostics through Comfy v3 only.""" + + LEGACY_NODE_CLASS = LatentDiagnostics + NODE_ID = "SimpleSyrup.LatentDiagnostics" + DISPLAY_NAME = "Latent Diagnostics" + + +class PromptEncodeStyleV3(LegacyNodeV3Adapter): + """Expose Prompt Encode Style through Comfy v3 only.""" + + LEGACY_NODE_CLASS = PromptEncodeStyle + NODE_ID = "SimpleSyrup.PromptEncodeStyle" + DISPLAY_NAME = "Prompt Encode Style" + + +class PromptEncodeStyleAndNormalizationV3(LegacyNodeV3Adapter): + """Expose Prompt Encode Style and Normalization through Comfy v3 only.""" + + LEGACY_NODE_CLASS = PromptEncodeStyleAndNormalization + NODE_ID = "SimpleSyrup.PromptEncodeStyleAndNormalization" + DISPLAY_NAME = "Prompt Encode Style & Normalization" + + +class PromptSEGSWithSAMV3(LegacyNodeV3Adapter): + """Expose Prompt SEGS w/ SAM through Comfy v3 only.""" + + LEGACY_NODE_CLASS = PromptSEGSWithSAM + NODE_ID = "SimpleSyrup.PromptSEGSWithSAM" + DISPLAY_NAME = "Prompt SEGS w/ SAM" + + +class SimpleVAEEncodeV3(LegacyNodeV3Adapter): + """Expose Simple VAE Encode through Comfy v3 only.""" + + LEGACY_NODE_CLASS = SimpleVAEEncode + NODE_ID = "SimpleSyrup.SimpleVAEEncode" + DISPLAY_NAME = "Simple VAE Encode" + ENABLE_EXPAND = True + + +class UpscaleLatentFromImageV3(LegacyNodeV3Adapter): + """Expose Upscale Latent From Image through Comfy v3 only.""" + + LEGACY_NODE_CLASS = UpscaleLatentFromImage + NODE_ID = "SimpleSyrup.UpscaleLatentFromImage" + DISPLAY_NAME = "Upscale Latent From Image" + ENABLE_EXPAND = True + + +class ResizeImageToTargetV3(LegacyNodeV3Adapter): + """Expose Resize Image to Target through Comfy v3 only.""" + + LEGACY_NODE_CLASS = ResizeImageToTarget + NODE_ID = "SimpleSyrup.ResizeImageToTarget" + DISPLAY_NAME = "Resize Image to Target" + + +class DetailSEGSAsRegionsV3(LegacyNodeV3Adapter): + """Expose Detail SEGS as Regions through Comfy v3 only.""" + + LEGACY_NODE_CLASS = DetailSEGSAsRegions + NODE_ID = "SimpleSyrup.DetailSEGSAsRegions" + DISPLAY_NAME = "Detail SEGS as Regions" + + +class DetailSEGSByScaleFactorV3(LegacyNodeV3Adapter): + """Expose Detail SEGS by Scale Factor through Comfy v3 only.""" + + LEGACY_NODE_CLASS = DetailSEGSByScaleFactor + NODE_ID = "SimpleSyrup.DetailSEGSByScaleFactor" + DISPLAY_NAME = "Detail SEGS by Scale Factor" + + +class DetailSEGSByScaleFactorTiledDiffusionV3(LegacyNodeV3Adapter): + """Expose Detail SEGS by Scale Factor with Tiled Diffusion through Comfy v3.""" + + LEGACY_NODE_CLASS = DetailSEGSByScaleFactorTiledDiffusion + NODE_ID = "SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion" + DISPLAY_NAME = "Detail SEGS by Scale Factor w/ Tiled Diffusion" + + +class SAMModelLoaderV3(LegacyNodeV3Adapter): + """Expose SAM Model Loader through Comfy v3 only.""" + + LEGACY_NODE_CLASS = SAMModelLoader + NODE_ID = "SimpleSyrup.SAMModelLoader" + DISPLAY_NAME = "SAM Model Loader" + + +class SeedV3(LegacyNodeV3Adapter): + """Expose Seed through Comfy v3 only.""" + + LEGACY_NODE_CLASS = Seed + NODE_ID = "SimpleSyrup.Seed" + DISPLAY_NAME = "Seed" + + +class SimpleLoadAnimaV3(LegacyNodeV3Adapter): + """Expose Simple Load Anima through Comfy v3 only.""" + + LEGACY_NODE_CLASS = SimpleLoadAnima + NODE_ID = "SimpleSyrup.SimpleLoadAnima" + DISPLAY_NAME = "Simple Load Anima" + + +class LoadUltralyticsModelV3(LegacyNodeV3Adapter): + """Expose Load Ultralytics Model through Comfy v3 only.""" + + LEGACY_NODE_CLASS = LoadUltralyticsModel + NODE_ID = "SimpleSyrup.LoadUltralyticsModel" + DISPLAY_NAME = "Load Ultralytics Model" + + +class DetectSEGSWithUltralyticsV3(LegacyNodeV3Adapter): + """Expose Detect SEGS w/ Ultralytics through Comfy v3 only.""" + + LEGACY_NODE_CLASS = DetectSEGSWithUltralytics + NODE_ID = "SimpleSyrup.DetectSEGSWithUltralytics" + DISPLAY_NAME = "Detect SEGS w/ Ultralytics" + + +class EncodePromptBatchV3(LegacyNodeV3Adapter): + """Expose Encode Prompt Batch through Comfy v3 only.""" + + LEGACY_NODE_CLASS = EncodePromptBatch + NODE_ID = "SimpleSyrup.EncodePromptBatch" + DISPLAY_NAME = "Encode Prompt Batch" + + +class ViTMatteModelLoaderV3(LegacyNodeV3Adapter): + """Expose ViTMatte Model Loader through Comfy v3 only.""" + + LEGACY_NODE_CLASS = ViTMatteModelLoader + NODE_ID = "SimpleSyrup.ViTMatteModelLoader" + DISPLAY_NAME = "ViTMatte Model Loader" + + +def _v3_inputs(input_types: Mapping[str, Mapping[str, object]]) -> list[Any]: + """Return v3 input declarations from legacy required and optional inputs.""" + + inputs: list[Any] = [] + for section_name, optional in (("required", False), ("optional", True)): + section = input_types.get(section_name, {}) + for name, declaration in section.items(): + inputs.append(_v3_input(name, declaration, optional=optional)) + return inputs + + +def _v3_input(name: str, declaration: object, *, optional: bool) -> Any: + """Return one v3 input declaration from a legacy field declaration.""" + + if not isinstance(declaration, tuple) or not declaration: + raise TypeError(f"legacy input {name} declaration must be a tuple.") + + io_declaration = declaration[0] + options = _input_options(declaration) + tooltip = _string_option(options, "tooltip") + advanced = _bool_option(options, "advanced") + raw_link = _bool_option(options, "rawLink") or _bool_option(options, "raw_link") + force_input = _bool_option(options, "forceInput") or _bool_option( + options, "force_input" + ) + + if isinstance(io_declaration, (list, tuple)): + return _comfy_io.Combo.Input( + name, + options=list(io_declaration), + optional=optional, + default=options.get("default"), + control_after_generate=options.get("control_after_generate"), + tooltip=tooltip, + raw_link=raw_link, + advanced=advanced, + ) + + if not isinstance(io_declaration, str): + raise TypeError(f"legacy input {name} type must be a string or options list.") + + input_type = io_declaration + input_class = _io_class(input_type) + common_options = { + "optional": optional, + "tooltip": tooltip, + "raw_link": raw_link, + "advanced": advanced, + } + + if input_type == "INT": + return input_class.Input( + name, + default=options.get("default"), + min=options.get("min"), + max=options.get("max"), + step=options.get("step"), + control_after_generate=options.get("control_after_generate"), + **common_options, + ) + if input_type == "FLOAT": + return input_class.Input( + name, + default=options.get("default"), + min=options.get("min"), + max=options.get("max"), + step=options.get("step"), + round=options.get("round"), + **common_options, + ) + if input_type == "STRING": + return input_class.Input( + name, + default=options.get("default"), + multiline=bool(options.get("multiline", False)), + force_input=force_input, + **common_options, + ) + if input_type == "BOOLEAN": + return input_class.Input( + name, + default=options.get("default"), + label_on=options.get("label_on"), + label_off=options.get("label_off"), + **common_options, + ) + + return input_class.Input(name, **common_options) + + +def _v3_outputs(legacy: type[Any]) -> list[Any]: + """Return v3 output declarations from legacy return metadata.""" + + return_types = tuple(getattr(legacy, "RETURN_TYPES", ())) + return_names = getattr(legacy, "RETURN_NAMES", None) + output_tooltips = tuple(getattr(legacy, "OUTPUT_TOOLTIPS", ())) + output_is_list = tuple( + getattr(legacy, "OUTPUT_IS_LIST", (False,) * len(return_types)) + ) + outputs: list[Any] = [] + for index, io_type in enumerate(return_types): + output_name = None + if isinstance(return_names, tuple) and index < len(return_names): + output_name = str(return_names[index]) + tooltip = None + if index < len(output_tooltips): + tooltip = str(output_tooltips[index]) + is_output_list = index < len(output_is_list) and bool(output_is_list[index]) + outputs.append( + _io_class(str(io_type)).Output( + output_name, + tooltip=tooltip, + is_output_list=is_output_list, + ) + ) + return outputs + + +def _v3_hidden_inputs(input_types: Mapping[str, Mapping[str, object]]) -> list[Any]: + """Return v3 hidden declarations requested by legacy hidden inputs.""" + + hidden_values = set(_legacy_hidden_inputs(input_types).values()) + return [getattr(_comfy_io.Hidden, value) for value in sorted(hidden_values)] + + +def _legacy_hidden_inputs( + input_types: Mapping[str, Mapping[str, object]], +) -> dict[str, str]: + """Return legacy hidden input names mapped to v3 hidden holder attributes.""" + + hidden_inputs: dict[str, str] = {} + for name, sentinel in input_types.get("hidden", {}).items(): + if isinstance(sentinel, str) and sentinel in _HIDDEN_INPUTS: + hidden_inputs[name] = _HIDDEN_INPUTS[sentinel] + return hidden_inputs + + +def _io_class(io_type: str) -> Any: + """Return the v3 IO class for a legacy Comfy type string.""" + + known_types = { + "BOOLEAN": _comfy_io.Boolean, + "INT": _comfy_io.Int, + "FLOAT": _comfy_io.Float, + "STRING": _comfy_io.String, + "IMAGE": _comfy_io.Image, + "MASK": _comfy_io.Mask, + "LATENT": _comfy_io.Latent, + "MODEL": _comfy_io.Model, + "CLIP": _comfy_io.Clip, + "VAE": _comfy_io.Vae, + "CONDITIONING": _comfy_io.Conditioning, + "SEGS": _comfy_io.SEGS, + } + return known_types.get(io_type, _comfy_io.Custom(io_type)) + + +def _input_options(declaration: tuple[object, ...]) -> dict[str, object]: + """Return an input options dictionary from a legacy declaration.""" + + if len(declaration) < 2 or not isinstance(declaration[1], dict): + return {} + return dict(declaration[1]) + + +def _string_option(options: Mapping[str, object], name: str) -> str | None: + """Return a string option when present.""" + + value = options.get(name) + if isinstance(value, str): + return value + return None + + +def _bool_option(options: Mapping[str, object], name: str) -> bool | None: + """Return a boolean option when present.""" + + value = options.get(name) + if isinstance(value, bool): + return value + return None + + +__all__ = [ + "ConditioningBatchAppendV3", + "ConditioningBatchStartV3", + "DetailSEGSAsRegionsV3", + "DetailSEGSByScaleFactorTiledDiffusionV3", + "DetailSEGSByScaleFactorV3", + "DetectSEGSWithUltralyticsV3", + "EncodePromptBatchV3", + "GroundedSAMModelInfoV3", + "GroundingDINOModelLoaderV3", + "KSamplerExtrasV3", + "KSamplerTiledDiffusionV3", + "LatentDiagnosticsV3", + "LayerStyleSAMModelsAdapterV3", + "LoadUltralyticsModelV3", + "PromptEncodeStyleAndNormalizationV3", + "PromptEncodeStyleV3", + "PromptSEGSWithSAMV3", + "ResizeImageToTargetV3", + "SAMModelLoaderV3", + "SeedV3", + "SimpleLoadAnimaV3", + "SimpleVAEEncodeV3", + "UpscaleLatentFromImageV3", + "ViTMatteModelLoaderV3", +] diff --git a/simple_syrup/runtime/external_llm_client.py b/simple_syrup/runtime/external_llm_client.py new file mode 100644 index 0000000..cbed6b6 --- /dev/null +++ b/simple_syrup/runtime/external_llm_client.py @@ -0,0 +1,241 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""OpenAI-compatible HTTP client for external LLM providers.""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from collections.abc import Callable +from typing import Any, Protocol, cast + +from ..domain.external_llm import ( + ExternalLLMChatRequest, + ExternalLLMChatResponse, + ExternalLLMProviderError, + normalize_base_url, +) +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) +MODELS_TIMEOUT_SECONDS = 10 +CHAT_TIMEOUT_SECONDS = 60 + + +class UrlopenResponse(Protocol): + """Subset of urllib response behavior used by the client.""" + + status: int + + def read(self) -> bytes: + """Read response bytes.""" + + def __enter__(self) -> UrlopenResponse: + """Enter a response context manager.""" + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: object | None, + ) -> None: + """Exit a response context manager.""" + + +Urlopen = Callable[[urllib.request.Request, int], UrlopenResponse] + + +class ExternalLLMClient: + """Call OpenAI-compatible model listing and chat completion endpoints.""" + + def __init__(self, urlopen: Urlopen | None = None) -> None: + """Create a client with injectable HTTP transport.""" + + self._urlopen = urlopen or _default_urlopen + + def list_models(self, base_url: str, api_key: str) -> tuple[str, ...]: + """Return provider model ids from `/models`.""" + + payload = self._request_json( + base_url=base_url, + path="/models", + api_key=api_key, + method="GET", + body=None, + timeout=MODELS_TIMEOUT_SECONDS, + ) + return parse_models_payload(payload) + + def create_chat_completion( + self, + base_url: str, + api_key: str, + request: ExternalLLMChatRequest, + ) -> ExternalLLMChatResponse: + """Return assistant content from `/chat/completions`.""" + + messages: list[dict[str, object]] = [ + {"role": "system", "content": request.system_prompt}, + {"role": "user", "content": _user_message_content(request)}, + ] + payload = self._request_json( + base_url=base_url, + path="/chat/completions", + api_key=api_key, + method="POST", + body=_chat_completion_body(request, messages), + timeout=CHAT_TIMEOUT_SECONDS, + ) + return ExternalLLMChatResponse(parse_chat_content(payload)) + + def _request_json( + self, + base_url: str, + path: str, + api_key: str, + method: str, + body: dict[str, object] | None, + timeout: int, + ) -> object: + """Send an authenticated provider request and decode JSON.""" + + endpoint = f"{normalize_base_url(base_url)}{path}" + data = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request( + endpoint, + data=data, + method=method, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + ) + + try: + with self._urlopen(request, timeout) as response: + raw = response.read() + except urllib.error.HTTPError as error: + LOGGER.warning( + "external llm provider returned http error", + extra={"operation": path, "status": error.code}, + ) + raise ExternalLLMProviderError( + f"External LLM provider returned HTTP {error.code}." + ) from error + except (urllib.error.URLError, TimeoutError) as error: + LOGGER.warning( + "external llm provider request failed", + extra={"operation": path, "reason": str(error)}, + ) + raise ExternalLLMProviderError( + "External LLM provider request failed before a response was received." + ) from error + + try: + return json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ExternalLLMProviderError( + "External LLM provider returned invalid JSON." + ) from error + + +def parse_models_payload(payload: object) -> tuple[str, ...]: + """Extract de-duplicated model ids from an OpenAI-compatible payload.""" + + if not isinstance(payload, dict): + raise ExternalLLMProviderError( + "External LLM provider returned an invalid models response." + ) + data = payload.get("data") + if not isinstance(data, list): + raise ExternalLLMProviderError( + "External LLM provider returned an invalid models response." + ) + + models: list[str] = [] + seen: set[str] = set() + for item in data: + if not isinstance(item, dict): + continue + model_id: Any = item.get("id") + if not isinstance(model_id, str): + continue + model = model_id.strip() + if model and model not in seen: + seen.add(model) + models.append(model) + return tuple(models) + + +def _default_urlopen( + request: urllib.request.Request, + timeout: int, +) -> UrlopenResponse: + """Open a URL request with an explicit timeout.""" + + return cast(UrlopenResponse, urllib.request.urlopen(request, timeout=timeout)) + + +def _user_message_content(request: ExternalLLMChatRequest) -> object: + """Return text-only or multimodal user message content.""" + + if request.image_data_url is None: + return request.user_prompt + return [ + {"type": "text", "text": request.user_prompt}, + { + "type": "image_url", + "image_url": {"url": request.image_data_url}, + }, + ] + + +def _chat_completion_body( + request: ExternalLLMChatRequest, + messages: list[dict[str, object]], +) -> dict[str, object]: + """Return an OpenAI-compatible chat request body with optional reasoning.""" + + body: dict[str, object] = { + "model": request.model, + "messages": messages, + "max_tokens": request.max_tokens, + } + if request.reasoning_effort in {"high", "medium", "low"}: + body["reasoning_effort"] = request.reasoning_effort + if request.reasoning_effort == "off": + body["chat_template_kwargs"] = {"thinking": False} + return body + + +def parse_chat_content(payload: object) -> str: + """Extract assistant message content from a chat completion payload.""" + + if not isinstance(payload, dict): + raise ExternalLLMProviderError( + "The external LLM provider returned an invalid chat completion response." + ) + choices = payload.get("choices") + if not isinstance(choices, list) or not choices: + raise ExternalLLMProviderError( + "The external LLM provider returned an invalid chat completion response." + ) + first = choices[0] + if not isinstance(first, dict): + raise ExternalLLMProviderError( + "The external LLM provider returned an invalid chat completion response." + ) + message = first.get("message") + if not isinstance(message, dict): + raise ExternalLLMProviderError( + "The external LLM provider returned an invalid chat completion response." + ) + content = message.get("content") + if not isinstance(content, str): + raise ExternalLLMProviderError( + "The external LLM provider returned an invalid chat completion response." + ) + return content diff --git a/simple_syrup/runtime/external_llm_images.py b/simple_syrup/runtime/external_llm_images.py new file mode 100644 index 0000000..6a4c660 --- /dev/null +++ b/simple_syrup/runtime/external_llm_images.py @@ -0,0 +1,47 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Image adaptation for OpenAI-compatible external LLM vision requests.""" + +from __future__ import annotations + +import base64 +from io import BytesIO + +import torch +from PIL import Image + +from ..shared.tensor_validation import validate_image_tensor + + +class ExternalLLMImageEncoder: + """Encode ComfyUI IMAGE tensors for OpenAI-compatible vision payloads.""" + + def encode_first_image_as_data_url(self, image: object) -> str: + """Return the first image in a ComfyUI IMAGE batch as a PNG data URL.""" + + validate_image_tensor(image) + if not isinstance(image, torch.Tensor): + raise TypeError("image must be a torch.Tensor with shape (B, H, W, C).") + + pil_image = _image_tensor_to_rgb_pil(image) + buffer = BytesIO() + pil_image.save(buffer, format="PNG") + encoded = base64.b64encode(buffer.getvalue()).decode("ascii") + return f"data:image/png;base64,{encoded}" + + +def _image_tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image: + """Convert the first BHWC image tensor item to RGB PIL image.""" + + import numpy as np + + array = image[0].detach().cpu().float().clamp(0.0, 1.0).numpy() + if array.shape[-1] == 1: + array = np.repeat(array, 3, axis=-1) + elif array.shape[-1] >= 3: + array = array[..., :3] + else: + array = np.repeat(array[..., :1], 3, axis=-1) + return Image.fromarray((array * 255.0).round().astype(np.uint8)) diff --git a/simple_syrup/runtime/external_llm_keyring.py b/simple_syrup/runtime/external_llm_keyring.py new file mode 100644 index 0000000..560904d --- /dev/null +++ b/simple_syrup/runtime/external_llm_keyring.py @@ -0,0 +1,102 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""OS credential storage adapter for external LLM API keys.""" + +from __future__ import annotations + +import importlib +from typing import Protocol, cast + +from ..domain.external_llm import ExternalLLMConfigError, normalize_base_url + +SERVICE_NAME = "SimpleSyrup" + + +class KeyringProtocol(Protocol): + """Subset of the keyring API used by SimpleSyrup.""" + + def get_password(self, service_name: str, username: str) -> str | None: + """Return a stored password or None.""" + + def set_password(self, service_name: str, username: str, password: str) -> None: + """Store a password.""" + + def delete_password(self, service_name: str, username: str) -> None: + """Delete a stored password.""" + + +class ExternalLLMKeyringError(RuntimeError): + """Raised when OS credential storage cannot complete an operation.""" + + +class ExternalLLMKeyringStore: + """Store external LLM API keys in the OS credential backend.""" + + def __init__(self, keyring_module: KeyringProtocol | None = None) -> None: + """Create a keyring store with an injectable credential backend.""" + + self._keyring = keyring_module + + def has_api_key(self, base_url: str) -> bool: + """Return whether an API key exists for the normalized endpoint.""" + + return self.get_api_key(base_url) != "" + + def get_api_key(self, base_url: str) -> str: + """Return the API key for the endpoint or an empty string.""" + + username = credential_username(base_url) + try: + return self._backend().get_password(SERVICE_NAME, username) or "" + except Exception as error: + raise ExternalLLMKeyringError( + "Could not read the external LLM API key from OS credential storage." + ) from error + + def save_api_key(self, base_url: str, api_key: str) -> None: + """Store an API key for the normalized endpoint.""" + + secret = api_key.strip() + if not secret: + raise ExternalLLMConfigError("External LLM API key must not be empty.") + + username = credential_username(base_url) + try: + self._backend().set_password(SERVICE_NAME, username, secret) + except Exception as error: + raise ExternalLLMKeyringError( + "Could not save the external LLM API key in OS credential storage." + ) from error + + def delete_api_key(self, base_url: str) -> None: + """Delete the API key for the normalized endpoint when present.""" + + username = credential_username(base_url) + try: + self._backend().delete_password(SERVICE_NAME, username) + except Exception as error: + raise ExternalLLMKeyringError( + "Could not delete the external LLM API key from OS credential storage." + ) from error + + def _backend(self) -> KeyringProtocol: + """Return the configured keyring backend.""" + + if self._keyring is not None: + return self._keyring + + try: + module = importlib.import_module("keyring") + except ModuleNotFoundError as error: + raise ExternalLLMKeyringError( + "The keyring package is required to store external LLM API keys." + ) from error + return cast(KeyringProtocol, module) + + +def credential_username(base_url: str) -> str: + """Return the stable keyring username for an endpoint.""" + + return f"external-llm:{normalize_base_url(base_url)}" diff --git a/simple_syrup/runtime/external_llm_routes.py b/simple_syrup/runtime/external_llm_routes.py new file mode 100644 index 0000000..40a0dfe --- /dev/null +++ b/simple_syrup/runtime/external_llm_routes.py @@ -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 ..services.external_llm_prompt_service import ExternalLLMPromptService +from ..shared.logging import get_logger +from .external_llm_keyring import ExternalLLMKeyringError + +LOGGER = get_logger(__name__) +EXTERNAL_LLM_SETTINGS_ROUTE = "/simple-syrup/external-llm/settings" +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) diff --git a/simple_syrup/runtime/settings.py b/simple_syrup/runtime/settings.py index 182d79e..9054b04 100644 --- a/simple_syrup/runtime/settings.py +++ b/simple_syrup/runtime/settings.py @@ -8,12 +8,17 @@ from __future__ import annotations import importlib import json -from dataclasses import dataclass +from dataclasses import dataclass, field from json import JSONDecodeError from pathlib import Path from types import ModuleType from typing import Any, Final +from ..domain.external_llm import ( + ExternalLLMConfigError, + normalize_base_url, + normalize_model_ids, +) from ..shared.logging import get_logger LOGGER = get_logger(__name__) @@ -24,16 +29,83 @@ class SimpleSyrupSettingsError(ValueError): """Raised when SimpleSyrup settings data is malformed.""" +@dataclass(frozen=True) +class ExternalLLMSettings: + """Non-secret external LLM provider settings persisted in Comfy's user data.""" + + base_url: str = "" + cached_models: tuple[str, ...] = () + default_model: str = "" + + def to_payload(self) -> dict[str, object]: + """Return the validated external LLM JSON payload shape.""" + + return { + "base_url": self.base_url, + "cached_models": list(self.cached_models), + "default_model": self.default_model, + } + + @classmethod + def from_payload(cls, payload: object) -> ExternalLLMSettings: + """Create external LLM settings from a decoded JSON payload.""" + + if payload is None: + return cls() + if not isinstance(payload, dict): + raise SimpleSyrupSettingsError( + "SimpleSyrup external_llm settings must be a JSON object." + ) + + base_url = payload.get("base_url", "") + if not isinstance(base_url, str): + raise SimpleSyrupSettingsError( + "SimpleSyrup external_llm.base_url must be a string." + ) + + default_model = payload.get("default_model", "") + if not isinstance(default_model, str): + raise SimpleSyrupSettingsError( + "SimpleSyrup external_llm.default_model must be a string." + ) + + try: + cached_models = normalize_model_ids(payload.get("cached_models", [])) + except ExternalLLMConfigError as error: + raise SimpleSyrupSettingsError(str(error)) from error + + default = default_model.strip() + if default and cached_models and default not in cached_models: + default = cached_models[0] + + normalized_url = "" + if base_url.strip(): + try: + normalized_url = normalize_base_url(base_url) + except ExternalLLMConfigError as error: + raise SimpleSyrupSettingsError(str(error)) from error + + return cls( + base_url=normalized_url, + cached_models=cached_models, + default_model=default, + ) + + @dataclass(frozen=True) class SimpleSyrupSettings: """User-configurable SimpleSyrup runtime settings.""" show_downloadable_models: bool = True + external_llm: ExternalLLMSettings = field(default_factory=ExternalLLMSettings) - def to_payload(self) -> dict[str, bool]: + def to_payload(self) -> dict[str, object]: """Return the validated JSON payload shape.""" - return {"show_downloadable_models": self.show_downloadable_models} + return { + "show_downloadable_models": self.show_downloadable_models, + "external_llm": self.external_llm.to_payload(), + } @classmethod def from_payload(cls, payload: object) -> SimpleSyrupSettings: @@ -50,7 +122,17 @@ class SimpleSyrupSettings: "SimpleSyrup settings payload is invalid. Expected " "show_downloadable_models to be a boolean." ) - return cls(show_downloadable_models=value) + + try: + external_llm = ExternalLLMSettings.from_payload(payload.get("external_llm")) + except SimpleSyrupSettingsError as error: + LOGGER.warning( + "using default external llm settings after failed load", + extra={"reason": str(error)}, + ) + external_llm = ExternalLLMSettings() + + return cls(show_downloadable_models=value, external_llm=external_llm) class SimpleSyrupSettingsRepository: diff --git a/simple_syrup/runtime/settings_routes.py b/simple_syrup/runtime/settings_routes.py index fef5aa5..123abc7 100644 --- a/simple_syrup/runtime/settings_routes.py +++ b/simple_syrup/runtime/settings_routes.py @@ -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,18 @@ 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 isinstance(payload, dict) and "external_llm" not in payload: + current = self._repository.load() + return SimpleSyrupSettings( + show_downloadable_models=settings.show_downloadable_models, + external_llm=current.external_llm, + ) + return settings + def register_settings_routes( repository: SimpleSyrupSettingsRepository | None = None, diff --git a/simple_syrup/services/external_llm_prompt_service.py b/simple_syrup/services/external_llm_prompt_service.py new file mode 100644 index 0000000..41cb8fa --- /dev/null +++ b/simple_syrup/services/external_llm_prompt_service.py @@ -0,0 +1,296 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Application service for external LLM prompt generation.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Protocol + +from ..domain.external_llm import ( + DEFAULT_EXTERNAL_LLM_MAX_TOKENS, + DEFAULT_EXTERNAL_LLM_REASONING_EFFORT, + ExternalLLMChatRequest, + ExternalLLMChatResponse, + ExternalLLMConfigError, + ExternalLLMProviderError, + normalize_base_url, +) +from ..runtime.external_llm_client import ExternalLLMClient +from ..runtime.external_llm_images import ExternalLLMImageEncoder +from ..runtime.external_llm_keyring import ExternalLLMKeyringStore +from ..runtime.settings import ( + ExternalLLMSettings, + SimpleSyrupSettings, + SimpleSyrupSettingsRepository, +) + +CONFIGURE_EXTERNAL_LLM = "Configure external LLM endpoint" + + +class SettingsRepository(Protocol): + """Settings persistence boundary used by external LLM services.""" + + def load(self) -> SimpleSyrupSettings: + """Load current SimpleSyrup settings.""" + + def save(self, settings: SimpleSyrupSettings) -> SimpleSyrupSettings: + """Persist current SimpleSyrup settings.""" + + +class KeyStore(Protocol): + """Credential storage boundary used by external LLM services.""" + + def has_api_key(self, base_url: str) -> bool: + """Return whether an API key exists for an endpoint.""" + + def get_api_key(self, base_url: str) -> str: + """Return an API key for an endpoint.""" + + def save_api_key(self, base_url: str, api_key: str) -> None: + """Store an API key for an endpoint.""" + + def delete_api_key(self, base_url: str) -> None: + """Delete an API key for an endpoint.""" + + +class ProviderClient(Protocol): + """External LLM provider client boundary.""" + + def list_models(self, base_url: str, api_key: str) -> tuple[str, ...]: + """Return provider model ids.""" + + def create_chat_completion( + self, + base_url: str, + api_key: str, + request: ExternalLLMChatRequest, + ) -> ExternalLLMChatResponse: + """Return provider chat completion content wrapper.""" + + +class ImageEncoder(Protocol): + """Image encoding boundary for external LLM vision inputs.""" + + def encode_first_image_as_data_url(self, image: object) -> str: + """Return the first ComfyUI IMAGE tensor item as a data URL.""" + + +class ExternalLLMPromptService: + """Coordinate external LLM settings, credentials, and provider calls.""" + + def __init__( + self, + settings_repository: SettingsRepository | None = None, + key_store: KeyStore | None = None, + client: ProviderClient | None = None, + image_encoder: ImageEncoder | None = None, + ) -> None: + """Create the service with injectable runtime boundaries.""" + + self._settings_repository = ( + settings_repository or SimpleSyrupSettingsRepository() + ) + self._key_store = key_store or ExternalLLMKeyringStore() + self._client = client or ExternalLLMClient() + self._image_encoder = image_encoder or ExternalLLMImageEncoder() + + def model_choices(self) -> list[str]: + """Return cached provider model choices for Comfy dropdowns.""" + + models = self._settings_repository.load().external_llm.cached_models + return list(models) if models else [CONFIGURE_EXTERNAL_LLM] + + def provider_is_configured(self) -> bool: + """Return whether endpoint and API key are configured.""" + + external = self._settings_repository.load().external_llm + return bool(external.base_url) and self._key_store.has_api_key( + external.base_url + ) + + def settings_payload(self) -> dict[str, object]: + """Return non-secret external LLM settings for frontend routes.""" + + external = self._settings_repository.load().external_llm + has_api_key = ( + self._key_store.has_api_key(external.base_url) + if external.base_url + else False + ) + return { + "base_url": external.base_url, + "cached_models": list(external.cached_models), + "default_model": external.default_model, + "has_api_key": has_api_key, + } + + def refresh_models(self) -> tuple[str, ...]: + """Refresh cached provider models when endpoint credentials exist.""" + + settings = self._settings_repository.load() + external = settings.external_llm + if not external.base_url: + return external.cached_models + + if not self._key_store.has_api_key(external.base_url): + return external.cached_models + + api_key = self._key_store.get_api_key(external.base_url) + models = self._client.list_models(external.base_url, api_key) + default_model = external.default_model + if models and default_model not in models: + default_model = models[0] + if not models: + default_model = "" + + saved = self._settings_repository.save( + replace( + settings, + external_llm=replace( + external, + cached_models=models, + default_model=default_model, + ), + ) + ) + return saved.external_llm.cached_models + + def save_config( + self, base_url: str, default_model: str = "" + ) -> SimpleSyrupSettings: + """Persist non-secret external LLM endpoint settings.""" + + settings = self._settings_repository.load() + normalized_url = normalize_base_url(base_url) if base_url.strip() else "" + endpoint_changed = normalized_url != settings.external_llm.base_url + cached_models = () if endpoint_changed else settings.external_llm.cached_models + default = default_model.strip() + if endpoint_changed: + default = "" + if default and cached_models and default not in cached_models: + raise ExternalLLMConfigError( + "Default external LLM model must be one of the cached models." + ) + + self._settings_repository.save( + replace( + settings, + external_llm=ExternalLLMSettings( + base_url=normalized_url, + cached_models=cached_models, + default_model=default, + ), + ) + ) + try: + self.refresh_models() + except ExternalLLMProviderError: + pass + return self._settings_repository.load() + + def save_api_key(self, api_key: str) -> SimpleSyrupSettings: + """Store the API key for the configured endpoint.""" + + settings = self._settings_repository.load() + base_url = settings.external_llm.base_url + if not base_url: + raise ExternalLLMConfigError( + "Configure an external LLM endpoint in SimpleSyrup settings before " + "adding an API key." + ) + self._key_store.save_api_key(base_url, api_key) + try: + self.refresh_models() + except ExternalLLMProviderError: + pass + return self._settings_repository.load() + + def delete_api_key(self) -> SimpleSyrupSettings: + """Delete the API key for the configured endpoint.""" + + settings = self._settings_repository.load() + base_url = settings.external_llm.base_url + if base_url: + self._key_store.delete_api_key(base_url) + return settings + + 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, + ) -> str: + """Generate an assistant response for the supplied prompt pair.""" + + selected_model = self._resolve_model_for_execution(model) + + external = self._settings_repository.load().external_llm + if not external.base_url: + raise ExternalLLMConfigError( + "Configure an external LLM endpoint in SimpleSyrup settings before " + "using this node." + ) + + api_key = self._key_store.get_api_key(external.base_url) + if not api_key: + raise ExternalLLMConfigError( + "Add an API key for the configured external LLM endpoint in " + "SimpleSyrup settings." + ) + + request = ExternalLLMChatRequest( + model=selected_model, + system_prompt=system_prompt, + user_prompt=user_prompt, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + image_data_url=( + None + if image is None + else self._image_encoder.encode_first_image_as_data_url(image) + ), + ) + response = self._client.create_chat_completion( + external.base_url, + api_key, + request, + ) + return response.content + + def _resolve_model_for_execution(self, model: str) -> str: + """Return a concrete provider model, refreshing stale sentinel choices.""" + + selected_model = model.strip() + if selected_model != CONFIGURE_EXTERNAL_LLM: + return selected_model + + settings = self._settings_repository.load() + external = settings.external_llm + if not external.base_url: + raise ExternalLLMConfigError( + "Configure an external LLM endpoint in SimpleSyrup settings before " + "using this node." + ) + if not self._key_store.has_api_key(external.base_url): + raise ExternalLLMConfigError( + "Add an API key for the configured external LLM endpoint in " + "SimpleSyrup settings." + ) + + models = external.cached_models or self.refresh_models() + refreshed_external = self._settings_repository.load().external_llm + default_model = refreshed_external.default_model + if default_model and default_model in models: + return default_model + if models: + return models[0] + raise ExternalLLMConfigError( + "The configured external LLM endpoint did not report any models. " + "Check the endpoint URL and API key in SimpleSyrup settings." + ) diff --git a/tests/test_external_llm_client.py b/tests/test_external_llm_client.py new file mode 100644 index 0000000..0a5ce2f --- /dev/null +++ b/tests/test_external_llm_client.py @@ -0,0 +1,264 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the OpenAI-compatible external LLM HTTP client.""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request + +import pytest + +from simple_syrup.domain.external_llm import ( + ExternalLLMChatRequest, + ExternalLLMProviderError, +) +from simple_syrup.runtime.external_llm_client import ExternalLLMClient + + +class FakeResponse: + """Context-managed HTTP response double.""" + + status = 200 + + def __init__(self, payload: object) -> None: + """Store JSON payload bytes.""" + + self._payload = json.dumps(payload).encode("utf-8") + + def read(self) -> bytes: + """Return response bytes.""" + + return self._payload + + def __enter__(self) -> FakeResponse: + """Enter context manager.""" + + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: object | None, + ) -> None: + """Exit context manager.""" + + +class CapturingUrlopen: + """Transport double that captures requests.""" + + def __init__(self, payload: object) -> None: + """Store the response payload.""" + + self.payload = payload + self.requests: list[tuple[urllib.request.Request, int]] = [] + + def __call__( + self, + request: urllib.request.Request, + timeout: int, + ) -> FakeResponse: + """Capture request and return configured response.""" + + self.requests.append((request, timeout)) + return FakeResponse(self.payload) + + +def test_list_models_sends_authenticated_models_request() -> None: + """Model refresh uses the OpenAI-compatible `/models` endpoint.""" + + urlopen = CapturingUrlopen( + {"data": [{"id": "model-a"}, {"id": "model-a"}, {"id": "model-b"}]} + ) + client = ExternalLLMClient(urlopen) + + models = client.list_models("https://provider.example/v1/", "secret") + + request, timeout = urlopen.requests[0] + assert request.full_url == "https://provider.example/v1/models" + assert request.get_method() == "GET" + assert request.headers["Authorization"] == "Bearer secret" + assert timeout == 10 + assert models == ("model-a", "model-b") + + +def test_list_models_rejects_malformed_response() -> None: + """Malformed provider model payloads fail clearly.""" + + client = ExternalLLMClient(CapturingUrlopen({"data": "bad"})) + + with pytest.raises(ExternalLLMProviderError, match="models response"): + client.list_models("https://provider.example/v1", "secret") + + +def test_chat_completion_sends_messages_and_returns_content() -> None: + """Chat completion uses system and user messages and extracts content.""" + + urlopen = CapturingUrlopen( + {"choices": [{"message": {"content": "rewritten prompt"}}]} + ) + client = ExternalLLMClient(urlopen) + + response = client.create_chat_completion( + "https://provider.example/v1", + "secret", + ExternalLLMChatRequest( + model="model-a", + system_prompt="system", + user_prompt="user", + ), + ) + + request, timeout = urlopen.requests[0] + assert request.full_url == "https://provider.example/v1/chat/completions" + assert request.get_method() == "POST" + assert timeout == 60 + assert isinstance(request.data, bytes) + assert json.loads(request.data.decode("utf-8")) == { + "model": "model-a", + "messages": [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "user"}, + ], + "max_tokens": 1024, + } + assert response.content == "rewritten prompt" + + +def test_chat_completion_sends_configured_max_tokens() -> None: + """Chat completion forwards the workflow response token limit.""" + + urlopen = CapturingUrlopen({"choices": [{"message": {"content": "ok"}}]}) + client = ExternalLLMClient(urlopen) + + client.create_chat_completion( + "https://provider.example/v1", + "secret", + ExternalLLMChatRequest( + model="model-a", + system_prompt="system", + user_prompt="user", + max_tokens=128, + ), + ) + + request, _timeout = urlopen.requests[0] + assert isinstance(request.data, bytes) + body = json.loads(request.data.decode("utf-8")) + assert body["max_tokens"] == 128 + + +def test_chat_completion_sends_reasoning_effort_when_requested() -> None: + """Reasoning effort high/medium/low is sent as a top-level field.""" + + urlopen = CapturingUrlopen({"choices": [{"message": {"content": "ok"}}]}) + client = ExternalLLMClient(urlopen) + + client.create_chat_completion( + "https://provider.example/v1", + "secret", + ExternalLLMChatRequest( + model="model-a", + system_prompt="system", + user_prompt="user", + reasoning_effort="low", + ), + ) + + request, _timeout = urlopen.requests[0] + assert isinstance(request.data, bytes) + body = json.loads(request.data.decode("utf-8")) + assert body["reasoning_effort"] == "low" + assert "chat_template_kwargs" not in body + + +def test_chat_completion_sends_thinking_disabled_for_off() -> None: + """The off option maps to chat template thinking disablement.""" + + urlopen = CapturingUrlopen({"choices": [{"message": {"content": "ok"}}]}) + client = ExternalLLMClient(urlopen) + + client.create_chat_completion( + "https://provider.example/v1", + "secret", + ExternalLLMChatRequest( + model="model-a", + system_prompt="system", + user_prompt="user", + reasoning_effort="off", + ), + ) + + request, _timeout = urlopen.requests[0] + assert isinstance(request.data, bytes) + body = json.loads(request.data.decode("utf-8")) + assert body["chat_template_kwargs"] == {"thinking": False} + assert "reasoning_effort" not in body + + +def test_chat_completion_sends_image_content_when_image_is_present() -> None: + """Vision requests use OpenAI-compatible multimodal user content.""" + + urlopen = CapturingUrlopen({"choices": [{"message": {"content": "caption"}}]}) + client = ExternalLLMClient(urlopen) + + client.create_chat_completion( + "https://provider.example/v1", + "secret", + ExternalLLMChatRequest( + model="model-a", + system_prompt="system", + user_prompt="describe this", + image_data_url="data:image/png;base64,abc", + ), + ) + + request, _timeout = urlopen.requests[0] + assert isinstance(request.data, bytes) + body = json.loads(request.data.decode("utf-8")) + assert body["messages"][1] == { + "role": "user", + "content": [ + {"type": "text", "text": "describe this"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,abc"}, + }, + ], + } + + +def test_chat_completion_rejects_malformed_response() -> None: + """Missing assistant content is reported as a provider response error.""" + + client = ExternalLLMClient(CapturingUrlopen({"choices": []})) + + with pytest.raises(ExternalLLMProviderError, match="chat completion"): + client.create_chat_completion( + "https://provider.example/v1", + "secret", + ExternalLLMChatRequest( + model="model-a", + system_prompt="system", + user_prompt="user", + ), + ) + + +def test_transport_failures_become_provider_errors() -> None: + """URL transport errors are wrapped as provider errors.""" + + def failing_urlopen( + _request: urllib.request.Request, + _timeout: int, + ) -> FakeResponse: + raise urllib.error.URLError("offline") + + client = ExternalLLMClient(failing_urlopen) + + with pytest.raises(ExternalLLMProviderError, match="request failed"): + client.list_models("https://provider.example/v1", "secret") diff --git a/tests/test_external_llm_images.py b/tests/test_external_llm_images.py new file mode 100644 index 0000000..bb359c4 --- /dev/null +++ b/tests/test_external_llm_images.py @@ -0,0 +1,25 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for external LLM image encoding.""" + +from __future__ import annotations + +import base64 + +import torch + +from simple_syrup.runtime.external_llm_images import ExternalLLMImageEncoder + + +def test_image_encoder_returns_png_data_url() -> None: + """ComfyUI IMAGE tensors are encoded as PNG data URLs.""" + + image = torch.zeros((1, 2, 2, 3), dtype=torch.float32) + + data_url = ExternalLLMImageEncoder().encode_first_image_as_data_url(image) + + assert data_url.startswith("data:image/png;base64,") + payload = data_url.removeprefix("data:image/png;base64,") + assert base64.b64decode(payload).startswith(b"\x89PNG") diff --git a/tests/test_external_llm_keyring.py b/tests/test_external_llm_keyring.py new file mode 100644 index 0000000..81f88df --- /dev/null +++ b/tests/test_external_llm_keyring.py @@ -0,0 +1,98 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for external LLM keyring credential storage.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.external_llm import ExternalLLMConfigError +from simple_syrup.runtime.external_llm_keyring import ( + ExternalLLMKeyringError, + ExternalLLMKeyringStore, + credential_username, +) + + +class FakeKeyring: + """In-memory keyring double.""" + + def __init__(self) -> None: + """Create empty credential storage.""" + + self.passwords: dict[tuple[str, str], str] = {} + + def get_password(self, service_name: str, username: str) -> str | None: + """Return a stored password.""" + + return self.passwords.get((service_name, username)) + + def set_password(self, service_name: str, username: str, password: str) -> None: + """Store a password.""" + + self.passwords[(service_name, username)] = password + + def delete_password(self, service_name: str, username: str) -> None: + """Delete a password.""" + + self.passwords.pop((service_name, username), None) + + +class FailingKeyring(FakeKeyring): + """Keyring double that fails all operations.""" + + def get_password(self, service_name: str, username: str) -> str | None: + """Raise on read.""" + + raise RuntimeError("backend failed") + + def set_password(self, service_name: str, username: str, password: str) -> None: + """Raise on write.""" + + raise RuntimeError("backend failed") + + def delete_password(self, service_name: str, username: str) -> None: + """Raise on delete.""" + + raise RuntimeError("backend failed") + + +def test_keyring_store_saves_reads_checks_and_deletes_api_key() -> None: + """API keys are stored under the normalized endpoint credential name.""" + + keyring = FakeKeyring() + store = ExternalLLMKeyringStore(keyring) + + store.save_api_key("https://provider.example/v1/", " secret ") + + assert store.has_api_key("https://provider.example/v1") is True + assert store.get_api_key("https://provider.example/v1") == "secret" + assert credential_username("https://provider.example/v1/").endswith( + "https://provider.example/v1" + ) + + store.delete_api_key("https://provider.example/v1") + + assert store.has_api_key("https://provider.example/v1") is False + + +def test_keyring_store_rejects_empty_api_key() -> None: + """Empty API keys fail before touching credential storage.""" + + store = ExternalLLMKeyringStore(FakeKeyring()) + + with pytest.raises(ExternalLLMConfigError, match="must not be empty"): + store.save_api_key("https://provider.example/v1", " ") + + +def test_keyring_failures_do_not_expose_api_key() -> None: + """Credential backend failures are wrapped without leaking secrets.""" + + store = ExternalLLMKeyringStore(FailingKeyring()) + + with pytest.raises(ExternalLLMKeyringError) as error: + store.save_api_key("https://provider.example/v1", "secret-value") + + assert "secret-value" not in str(error.value) diff --git a/tests/test_external_llm_prompt_node.py b/tests/test_external_llm_prompt_node.py new file mode 100644 index 0000000..128d47d --- /dev/null +++ b/tests/test_external_llm_prompt_node.py @@ -0,0 +1,102 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the legacy External LLM Prompt node.""" + +from __future__ import annotations + +from simple_syrup.nodes.external_llm_prompt import ExternalLLMPrompt +from simple_syrup.services.external_llm_prompt_service import CONFIGURE_EXTERNAL_LLM + + +class FakeExternalLLMService: + """Service double for node tests.""" + + def model_choices(self) -> list[str]: + """Return deterministic model choices.""" + + return ["model-a"] + + def generate( + self, + model: str, + system_prompt: str, + user_prompt: str, + max_tokens: int = 1024, + reasoning_effort: str = "default", + image: object | None = None, + ) -> str: + """Return a deterministic response.""" + + return ( + f"{model}:{system_prompt}:{user_prompt}:" + f"{max_tokens}:{reasoning_effort}:{image}" + ) + + +def test_external_llm_prompt_input_types_are_cached_and_single_line() -> None: + """The node exposes cached model choices and single-line prompt widgets.""" + + original_service = ExternalLLMPrompt._service + ExternalLLMPrompt._service = FakeExternalLLMService() # type: ignore[assignment] + try: + input_types = ExternalLLMPrompt.INPUT_TYPES() + finally: + ExternalLLMPrompt._service = original_service + + inputs = input_types["required"] + assert inputs["model"][0] == ["model-a"] + assert inputs["model"][1]["default"] == "model-a" + assert inputs["system_prompt"][0] == "STRING" + assert inputs["system_prompt"][1]["multiline"] is False + assert inputs["user_prompt"][0] == "STRING" + assert inputs["user_prompt"][1]["multiline"] is False + assert inputs["max_tokens"][0] == "INT" + assert inputs["max_tokens"][1]["default"] == 1024 + assert inputs["max_tokens"][1]["min"] == 1 + assert inputs["max_tokens"][1]["max"] == 32768 + assert inputs["reasoning_effort"][0] == [ + "default", + "high", + "medium", + "low", + "off", + ] + assert inputs["reasoning_effort"][1]["default"] == "default" + assert input_types["optional"]["image"][0] == "IMAGE" + + +def test_external_llm_prompt_output_contract() -> None: + """The node returns one named STRING output.""" + + assert ExternalLLMPrompt.RETURN_TYPES == ("STRING",) + assert ExternalLLMPrompt.RETURN_NAMES == ("response",) + assert ExternalLLMPrompt.FUNCTION == "generate" + + +def test_external_llm_prompt_executes_service() -> None: + """Node execution delegates to the prompt service.""" + + node = ExternalLLMPrompt() + original_service = node._service + node._service = FakeExternalLLMService() # type: ignore[assignment] + try: + result = node.generate( + "model-a", + "system", + "user", + 128, + "off", + image="image", + ) + finally: + node._service = original_service + + assert result == ("model-a:system:user:128:off:image",) + + +def test_external_llm_prompt_sentinel_constant_matches_plan() -> None: + """The setup sentinel is stable for dropdown fallback behavior.""" + + assert CONFIGURE_EXTERNAL_LLM == "Configure external LLM endpoint" diff --git a/tests/test_external_llm_prompt_service.py b/tests/test_external_llm_prompt_service.py new file mode 100644 index 0000000..f3ea272 --- /dev/null +++ b/tests/test_external_llm_prompt_service.py @@ -0,0 +1,423 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for external LLM prompt service orchestration.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.external_llm import ( + ExternalLLMChatRequest, + ExternalLLMChatResponse, + ExternalLLMConfigError, + ExternalLLMProviderError, +) +from simple_syrup.runtime.settings import ExternalLLMSettings, SimpleSyrupSettings +from simple_syrup.services.external_llm_prompt_service import ( + CONFIGURE_EXTERNAL_LLM, + ExternalLLMPromptService, +) + + +class FakeSettingsRepository: + """In-memory settings repository.""" + + def __init__(self, settings: SimpleSyrupSettings) -> None: + """Store initial settings.""" + + self.settings = settings + + def load(self) -> SimpleSyrupSettings: + """Return current settings.""" + + return self.settings + + def save(self, settings: SimpleSyrupSettings) -> SimpleSyrupSettings: + """Persist current settings.""" + + self.settings = settings + return settings + + +class FakeKeyStore: + """Credential store double.""" + + def __init__(self, api_key: str = "") -> None: + """Store a fake API key.""" + + self.api_key = api_key + + def has_api_key(self, _base_url: str) -> bool: + """Return whether a key exists.""" + + return bool(self.api_key) + + def get_api_key(self, _base_url: str) -> str: + """Return the fake key.""" + + return self.api_key + + def save_api_key(self, _base_url: str, api_key: str) -> None: + """Save the fake key.""" + + self.api_key = api_key + + def delete_api_key(self, _base_url: str) -> None: + """Delete the fake key.""" + + self.api_key = "" + + +class FakeClient: + """Provider client double.""" + + def __init__(self, models: tuple[str, ...] = ("model-a",)) -> None: + """Store provider models.""" + + self.models = models + self.requests: list[ExternalLLMChatRequest] = [] + self.list_model_calls = 0 + + def list_models(self, _base_url: str, _api_key: str) -> tuple[str, ...]: + """Return configured models.""" + + self.list_model_calls += 1 + return self.models + + def create_chat_completion( + self, + _base_url: str, + _api_key: str, + request: ExternalLLMChatRequest, + ) -> ExternalLLMChatResponse: + """Capture request and return content.""" + + self.requests.append(request) + return ExternalLLMChatResponse("assistant response") + + +class FailingModelClient(FakeClient): + """Provider client double that cannot refresh models.""" + + def list_models(self, _base_url: str, _api_key: str) -> tuple[str, ...]: + """Raise a provider failure during model refresh.""" + + self.list_model_calls += 1 + raise ExternalLLMProviderError("The external LLM provider rejected /models.") + + +class FakeImageEncoder: + """Image encoder double.""" + + def encode_first_image_as_data_url(self, image: object) -> str: + """Return a deterministic data URL.""" + + assert image == "image" + return "data:image/png;base64,abc" + + +def test_model_choices_return_sentinel_without_cached_models() -> None: + """No cached models produces a clear sentinel choice.""" + + service = ExternalLLMPromptService( + FakeSettingsRepository(SimpleSyrupSettings()), + FakeKeyStore(), + FakeClient(), + ) + + assert service.model_choices() == [CONFIGURE_EXTERNAL_LLM] + + +def test_refresh_models_skips_when_unconfigured() -> None: + """Refresh does no network work without endpoint credentials.""" + + repository = FakeSettingsRepository(SimpleSyrupSettings()) + client = FakeClient(("model-a",)) + service = ExternalLLMPromptService(repository, FakeKeyStore(), client) + + assert service.refresh_models() == () + assert repository.load().external_llm.cached_models == () + + +def test_refresh_models_persists_provider_models() -> None: + """Refresh stores sanitized provider model choices.""" + + repository = FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + cached_models=("old-model",), + default_model="old-model", + ) + ) + ) + service = ExternalLLMPromptService( + repository, + FakeKeyStore("secret"), + FakeClient(("model-a", "model-b")), + ) + + assert service.refresh_models() == ("model-a", "model-b") + assert repository.load().external_llm.default_model == "model-a" + + +def test_save_config_clears_cached_models_when_endpoint_changes() -> None: + """Provider model cache belongs to the configured endpoint.""" + + repository = FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://old-provider.example/v1", + cached_models=("old-model",), + default_model="old-model", + ) + ) + ) + service = ExternalLLMPromptService(repository, FakeKeyStore(), FakeClient()) + + saved = service.save_config("https://new-provider.example/v1") + + assert saved.external_llm.base_url == "https://new-provider.example/v1" + assert saved.external_llm.cached_models == () + assert saved.external_llm.default_model == "" + + +def test_save_config_preserves_cached_models_for_same_endpoint() -> None: + """Saving the existing endpoint keeps its model cache and default choice.""" + + repository = FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + cached_models=("model-a", "model-b"), + default_model="model-a", + ) + ) + ) + service = ExternalLLMPromptService(repository, FakeKeyStore(), FakeClient()) + + saved = service.save_config( + "https://provider.example/v1/", + default_model="model-b", + ) + + assert saved.external_llm.cached_models == ("model-a", "model-b") + assert saved.external_llm.default_model == "model-b" + + +def test_save_config_refreshes_models_when_endpoint_has_key() -> None: + """Saving an endpoint updates the available model cache when possible.""" + + repository = FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + cached_models=("old-model",), + default_model="old-model", + ) + ) + ) + client = FakeClient(("model-a", "model-b")) + service = ExternalLLMPromptService(repository, FakeKeyStore("secret"), client) + + saved = service.save_config("https://provider.example/v1") + + assert client.list_model_calls == 1 + assert saved.external_llm.cached_models == ("model-a", "model-b") + assert saved.external_llm.default_model == "model-a" + + +def test_save_config_succeeds_when_model_refresh_fails() -> None: + """Endpoint storage is not blocked by provider model discovery failures.""" + + repository = FakeSettingsRepository(SimpleSyrupSettings()) + service = ExternalLLMPromptService( + repository, + FakeKeyStore("secret"), + FailingModelClient(), + ) + + saved = service.save_config("https://provider.example/v1") + + assert saved.external_llm.base_url == "https://provider.example/v1" + assert saved.external_llm.cached_models == () + + +def test_save_api_key_persists_key_when_model_refresh_fails() -> None: + """Credential storage is independent from provider model discovery.""" + + key_store = FakeKeyStore() + service = ExternalLLMPromptService( + FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + ) + ) + ), + key_store, + FailingModelClient(), + ) + + saved = service.save_api_key("secret") + + assert key_store.has_api_key(saved.external_llm.base_url) is True + assert saved.external_llm.cached_models == () + + +def test_generate_rejects_sentinel_when_unconfigured() -> None: + """The setup sentinel still requires endpoint configuration.""" + + service = ExternalLLMPromptService( + FakeSettingsRepository(SimpleSyrupSettings()), + FakeKeyStore(), + FakeClient(), + ) + + with pytest.raises(ExternalLLMConfigError, match="Configure an external LLM"): + service.generate(CONFIGURE_EXTERNAL_LLM, "", "prompt") + + +def test_generate_refreshes_stale_sentinel_and_uses_default_model() -> None: + """Execution recovers when the graph still holds the setup sentinel.""" + + repository = FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + ) + ) + ) + client = FakeClient(("model-a", "model-b")) + service = ExternalLLMPromptService(repository, FakeKeyStore("secret"), client) + + assert service.generate(CONFIGURE_EXTERNAL_LLM, "system", "user") == ( + "assistant response" + ) + assert client.list_model_calls == 1 + assert client.requests[0].model == "model-a" + assert repository.load().external_llm.cached_models == ("model-a", "model-b") + + +def test_generate_uses_cached_default_when_sentinel_is_stale() -> None: + """A stale sentinel resolves to the saved default without provider refresh.""" + + repository = FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + cached_models=("model-a", "model-b"), + default_model="model-b", + ) + ) + ) + client = FakeClient(("model-c",)) + service = ExternalLLMPromptService(repository, FakeKeyStore("secret"), client) + + assert service.generate(CONFIGURE_EXTERNAL_LLM, "system", "user") == ( + "assistant response" + ) + assert client.list_model_calls == 0 + assert client.requests[0].model == "model-b" + + +def test_generate_rejects_empty_user_prompt() -> None: + """User prompt validation happens before provider work.""" + + service = configured_service() + + with pytest.raises(ExternalLLMConfigError, match="User prompt"): + service.generate("model-a", "", " ") + + +def test_generate_returns_provider_response() -> None: + """Prompt generation delegates to the provider client.""" + + service = configured_service() + + assert service.generate("model-a", "system", "user") == "assistant response" + + +def test_generate_forwards_max_tokens() -> None: + """Prompt generation includes the requested response token limit.""" + + client = FakeClient() + service = configured_service(client=client) + + assert service.generate("model-a", "system", "user", max_tokens=128) == ( + "assistant response" + ) + assert client.requests[0].max_tokens == 128 + + +def test_generate_forwards_reasoning_effort() -> None: + """Prompt generation includes the requested reasoning behavior.""" + + client = FakeClient() + service = configured_service(client=client) + + assert ( + service.generate( + "model-a", + "system", + "user", + reasoning_effort="off", + ) + == "assistant response" + ) + assert client.requests[0].reasoning_effort == "off" + + +def test_generate_rejects_invalid_max_tokens() -> None: + """Response token limit must be a positive integer.""" + + service = configured_service() + + with pytest.raises(ExternalLLMConfigError, match="max tokens"): + service.generate("model-a", "system", "user", max_tokens=0) + + +def test_generate_rejects_invalid_reasoning_effort() -> None: + """Reasoning effort must be one of the node choices.""" + + service = configured_service() + + with pytest.raises(ExternalLLMConfigError, match="reasoning effort"): + service.generate("model-a", "system", "user", reasoning_effort="none") + + +def test_generate_attaches_encoded_image_when_supplied() -> None: + """Optional image inputs are encoded before provider chat requests.""" + + client = FakeClient() + service = configured_service(client=client, image_encoder=FakeImageEncoder()) + + assert ( + service.generate("model-a", "system", "user", image="image") + == "assistant response" + ) + assert client.requests[0].image_data_url == "data:image/png;base64,abc" + + +def configured_service( + client: FakeClient | None = None, + image_encoder: FakeImageEncoder | None = None, +) -> ExternalLLMPromptService: + """Create a service with endpoint, cached model, key, and fake client.""" + + return ExternalLLMPromptService( + FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + cached_models=("model-a",), + default_model="model-a", + ) + ) + ), + FakeKeyStore("secret"), + client or FakeClient(), + image_encoder=image_encoder, + ) diff --git a/tests/test_external_llm_prompt_v3_node.py b/tests/test_external_llm_prompt_v3_node.py new file mode 100644 index 0000000..70e124d --- /dev/null +++ b/tests/test_external_llm_prompt_v3_node.py @@ -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 + +"""Tests for the External LLM Prompt Comfy v3 wrapper.""" + +from __future__ import annotations + +from typing import Any + +from simple_syrup.nodes.external_llm_prompt import ExternalLLMPrompt +from simple_syrup.nodes_v3.external_llm_prompt import ExternalLLMPromptV3 + + +class FakeExternalLLMService: + """Service double for v3 wrapper tests.""" + + def model_choices(self) -> list[str]: + """Return deterministic model choices.""" + + return ["model-a"] + + def generate( + self, + model: str, + system_prompt: str, + user_prompt: str, + max_tokens: int = 1024, + reasoning_effort: str = "default", + image: object | None = None, + ) -> str: + """Return a deterministic response.""" + + return ( + f"{model}:{system_prompt}:{user_prompt}:" + f"{max_tokens}:{reasoning_effort}:{image}" + ) + + +def test_external_llm_prompt_v3_schema_includes_max_tokens() -> None: + """The v3 schema exposes the response token limit.""" + + schema = ExternalLLMPromptV3.define_schema() + + assert schema.node_id == "SimpleSyrup.ExternalLLMPrompt" + assert [input_item.id for input_item in schema.inputs] == [ + "model", + "system_prompt", + "user_prompt", + "max_tokens", + "reasoning_effort", + "image", + ] + max_tokens = schema.inputs[3] + assert max_tokens.io_type == "INT" + assert max_tokens.default == 1024 + assert max_tokens.min == 1 + assert max_tokens.max == 32768 + reasoning_effort = schema.inputs[4] + assert reasoning_effort.io_type == "COMBO" + assert reasoning_effort.options == ["default", "high", "medium", "low", "off"] + assert reasoning_effort.default == "default" + + +def test_external_llm_prompt_v3_execute_forwards_max_tokens( + monkeypatch: Any, +) -> None: + """The v3 wrapper forwards max_tokens to the legacy implementation.""" + + monkeypatch.setattr(ExternalLLMPrompt, "_service", FakeExternalLLMService()) + + result = ExternalLLMPromptV3.execute( + model="model-a", + system_prompt="system", + user_prompt="user", + max_tokens=128, + reasoning_effort="off", + image="image", + ) + + assert result == ("model-a:system:user:128:off:image",) diff --git a/tests/test_external_llm_routes.py b/tests/test_external_llm_routes.py new file mode 100644 index 0000000..e81e697 --- /dev/null +++ b/tests/test_external_llm_routes.py @@ -0,0 +1,401 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for external LLM settings HTTP routes.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable +from typing import cast + +from aiohttp import web + +from simple_syrup.domain.external_llm import ( + ExternalLLMChatRequest, + ExternalLLMChatResponse, + ExternalLLMProviderError, +) +from simple_syrup.runtime.external_llm_routes import ( + EXTERNAL_LLM_API_KEY_ROUTE, + EXTERNAL_LLM_MODELS_REFRESH_ROUTE, + EXTERNAL_LLM_SETTINGS_ROUTE, + Handler, + PromptServerProtocol, + register_external_llm_routes, +) +from simple_syrup.runtime.settings import ExternalLLMSettings, SimpleSyrupSettings +from simple_syrup.services.external_llm_prompt_service import ExternalLLMPromptService + + +class FakeRoutes: + """Route table double that records handlers.""" + + def __init__(self) -> None: + """Create an empty route table.""" + + self.get_handlers: dict[str, Handler] = {} + self.post_handlers: dict[str, Handler] = {} + self.delete_handlers: dict[str, Handler] = {} + + def get(self, path: str) -> Callable[[Handler], Handler]: + """Record GET handlers.""" + + def decorator(handler: Handler) -> Handler: + self.get_handlers[path] = handler + return handler + + return decorator + + def post(self, path: str) -> Callable[[Handler], Handler]: + """Record POST handlers.""" + + def decorator(handler: Handler) -> Handler: + self.post_handlers[path] = handler + return handler + + return decorator + + def delete(self, path: str) -> Callable[[Handler], Handler]: + """Record DELETE handlers.""" + + def decorator(handler: Handler) -> Handler: + self.delete_handlers[path] = handler + return handler + + return decorator + + +class FakePromptServer: + """PromptServer double exposing route table.""" + + def __init__(self) -> None: + """Create fake prompt server.""" + + self.routes = FakeRoutes() + + +class FakeRequest: + """Request double with injectable JSON payload.""" + + def __init__(self, payload: object) -> None: + """Store payload.""" + + self._payload = payload + + async def json(self) -> object: + """Return configured payload.""" + + return self._payload + + +class FakeSettingsRepository: + """In-memory settings repository.""" + + def __init__(self, settings: SimpleSyrupSettings) -> None: + """Store initial settings.""" + + self.settings = settings + + def load(self) -> SimpleSyrupSettings: + """Return current settings.""" + + return self.settings + + def save(self, settings: SimpleSyrupSettings) -> SimpleSyrupSettings: + """Persist settings.""" + + self.settings = settings + return settings + + +class FakeKeyStore: + """Credential store double.""" + + def __init__(self, api_key: str = "") -> None: + """Store key.""" + + self.api_key = api_key + + def has_api_key(self, _base_url: str) -> bool: + """Return whether a key exists.""" + + return bool(self.api_key) + + def get_api_key(self, _base_url: str) -> str: + """Return key.""" + + return self.api_key + + def save_api_key(self, _base_url: str, api_key: str) -> None: + """Save key.""" + + self.api_key = api_key + + def delete_api_key(self, _base_url: str) -> None: + """Delete key.""" + + self.api_key = "" + + +class FakeClient: + """Provider client double.""" + + def list_models(self, _base_url: str, _api_key: str) -> tuple[str, ...]: + """Return fake models.""" + + return ("model-a", "model-b") + + def create_chat_completion( + self, + _base_url: str, + _api_key: str, + _request: ExternalLLMChatRequest, + ) -> ExternalLLMChatResponse: + """Return fake content.""" + + return ExternalLLMChatResponse("response") + + +class FailingModelClient(FakeClient): + """Provider client double that fails model discovery.""" + + def list_models(self, _base_url: str, _api_key: str) -> tuple[str, ...]: + """Raise a provider error during model refresh.""" + + raise ExternalLLMProviderError("The external LLM provider rejected /models.") + + +def test_external_llm_route_registration_records_handlers() -> None: + """External LLM routes register with Comfy's route table.""" + + prompt_server = FakePromptServer() + + assert register_fake_routes(configured_service(), prompt_server) is True + + assert EXTERNAL_LLM_SETTINGS_ROUTE in prompt_server.routes.get_handlers + assert EXTERNAL_LLM_SETTINGS_ROUTE in prompt_server.routes.post_handlers + assert EXTERNAL_LLM_API_KEY_ROUTE in prompt_server.routes.post_handlers + assert EXTERNAL_LLM_API_KEY_ROUTE in prompt_server.routes.delete_handlers + assert EXTERNAL_LLM_MODELS_REFRESH_ROUTE in prompt_server.routes.post_handlers + + +def test_get_settings_returns_non_secret_payload() -> None: + """GET returns endpoint settings and key presence without the key.""" + + prompt_server = FakePromptServer() + register_fake_routes(configured_service(api_key="secret"), prompt_server) + + response = asyncio.run( + prompt_server.routes.get_handlers[EXTERNAL_LLM_SETTINGS_ROUTE](object()) + ) + + payload = json.loads(response_text(response)) + assert response.status == 200 + assert payload == { + "base_url": "https://provider.example/v1", + "cached_models": ["model-a"], + "default_model": "model-a", + "has_api_key": True, + } + assert "secret" not in response_text(response) + + +def test_post_settings_validates_and_saves_payload() -> None: + """POST settings persists non-secret endpoint config.""" + + prompt_server = FakePromptServer() + service = ExternalLLMPromptService( + FakeSettingsRepository(SimpleSyrupSettings()), + FakeKeyStore(), + FakeClient(), + ) + register_fake_routes(service, prompt_server) + + response = asyncio.run( + prompt_server.routes.post_handlers[EXTERNAL_LLM_SETTINGS_ROUTE]( + FakeRequest( + { + "base_url": "https://provider.example/v1/", + "default_model": "", + } + ) + ) + ) + + payload = json.loads(response_text(response)) + assert response.status == 200 + assert payload["base_url"] == "https://provider.example/v1" + + +def test_post_settings_refreshes_models_when_key_exists() -> None: + """Saving endpoint settings updates model choices when credentials exist.""" + + prompt_server = FakePromptServer() + service = ExternalLLMPromptService( + FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + cached_models=("old-model",), + default_model="old-model", + ) + ) + ), + FakeKeyStore("secret"), + FakeClient(), + ) + register_fake_routes(service, prompt_server) + + response = asyncio.run( + prompt_server.routes.post_handlers[EXTERNAL_LLM_SETTINGS_ROUTE]( + FakeRequest( + { + "base_url": "https://provider.example/v1", + "default_model": "", + } + ) + ) + ) + + payload = json.loads(response_text(response)) + assert response.status == 200 + assert payload["cached_models"] == ["model-a", "model-b"] + + +def test_post_api_key_stores_key_and_refreshes_models() -> None: + """API key route stores the key without returning it.""" + + prompt_server = FakePromptServer() + register_fake_routes(configured_service(), prompt_server) + + response = asyncio.run( + prompt_server.routes.post_handlers[EXTERNAL_LLM_API_KEY_ROUTE]( + FakeRequest({"api_key": "secret"}) + ) + ) + + payload = json.loads(response_text(response)) + assert response.status == 200 + assert payload["has_api_key"] is True + assert payload["cached_models"] == ["model-a", "model-b"] + assert "secret" not in response_text(response) + + +def test_post_api_key_succeeds_when_model_refresh_fails() -> None: + """API key storage still succeeds if immediate model discovery fails.""" + + prompt_server = FakePromptServer() + service = ExternalLLMPromptService( + FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + ) + ) + ), + FakeKeyStore(), + FailingModelClient(), + ) + register_fake_routes(service, prompt_server) + + response = asyncio.run( + prompt_server.routes.post_handlers[EXTERNAL_LLM_API_KEY_ROUTE]( + FakeRequest({"api_key": "secret"}) + ) + ) + + payload = json.loads(response_text(response)) + assert response.status == 200 + assert payload["has_api_key"] is True + assert payload["cached_models"] == [] + assert "secret" not in response_text(response) + + +def test_delete_api_key_removes_key_presence() -> None: + """DELETE removes the stored API key.""" + + prompt_server = FakePromptServer() + register_fake_routes(configured_service(api_key="secret"), prompt_server) + + response = asyncio.run( + prompt_server.routes.delete_handlers[EXTERNAL_LLM_API_KEY_ROUTE](object()) + ) + + assert response.status == 200 + assert json.loads(response_text(response))["has_api_key"] is False + + +def test_refresh_models_updates_cache_when_configured() -> None: + """Refresh route updates cached model ids.""" + + prompt_server = FakePromptServer() + register_fake_routes(configured_service(api_key="secret"), prompt_server) + + response = asyncio.run( + prompt_server.routes.post_handlers[EXTERNAL_LLM_MODELS_REFRESH_ROUTE](object()) + ) + + assert response.status == 200 + assert json.loads(response_text(response))["cached_models"] == [ + "model-a", + "model-b", + ] + + +def test_refresh_models_skips_when_unconfigured() -> None: + """Refresh route succeeds without endpoint credentials.""" + + prompt_server = FakePromptServer() + service = ExternalLLMPromptService( + FakeSettingsRepository(SimpleSyrupSettings()), + FakeKeyStore(), + FakeClient(), + ) + register_fake_routes(service, prompt_server) + + response = asyncio.run( + prompt_server.routes.post_handlers[EXTERNAL_LLM_MODELS_REFRESH_ROUTE](object()) + ) + + assert response.status == 200 + assert json.loads(response_text(response))["cached_models"] == [] + + +def configured_service(api_key: str = "") -> ExternalLLMPromptService: + """Create a configured route service.""" + + return ExternalLLMPromptService( + FakeSettingsRepository( + SimpleSyrupSettings( + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + cached_models=("model-a",), + default_model="model-a", + ) + ) + ), + FakeKeyStore(api_key), + FakeClient(), + ) + + +def register_fake_routes( + service: ExternalLLMPromptService, + prompt_server: FakePromptServer, +) -> bool: + """Register routes against a fake server.""" + + return register_external_llm_routes( + service, + cast(PromptServerProtocol, prompt_server), + ) + + +def response_text(response: web.Response) -> str: + """Return response text after asserting it exists.""" + + assert response.text is not None + return response.text diff --git a/tests/test_legacy_node_v3_adapter.py b/tests/test_legacy_node_v3_adapter.py new file mode 100644 index 0000000..a313c2d --- /dev/null +++ b/tests/test_legacy_node_v3_adapter.py @@ -0,0 +1,84 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for v3 wrappers that delegate to existing implementation classes.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from simple_syrup.nodes_v3.legacy_node_wrappers import LegacyNodeV3Adapter + + +class _FakeHidden: + """Hidden holder double for adapter execution tests.""" + + prompt = {"node": "metadata"} + + +class _FakeLegacyNode: + """Legacy implementation double with visible and hidden inputs.""" + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("result",) + OUTPUT_TOOLTIPS = ("Delegated result.",) + FUNCTION = "run" + CATEGORY = "SimpleSyrup/Test" + DESCRIPTION = "Delegates test inputs." + SEARCH_ALIASES = ["adapter"] + + calls: ClassVar[list[tuple[str, object]]] = [] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...] | str]]: + """Return a small legacy contract with one hidden input.""" + + return { + "required": { + "text": ( + "STRING", + {"default": "hello", "tooltip": "Text to delegate."}, + ), + }, + "hidden": {"prompt": "PROMPT"}, + } + + def run(self, text: str, prompt: object | None = None) -> tuple[str]: + """Record delegated inputs and return the visible value.""" + + self.calls.append((text, prompt)) + return (text,) + + +class _FakeAdapter(LegacyNodeV3Adapter): + """Concrete adapter used to verify generic wrapper behavior.""" + + LEGACY_NODE_CLASS = _FakeLegacyNode + NODE_ID = "SimpleSyrup.FakeAdapter" + DISPLAY_NAME = "Fake Adapter" + hidden = _FakeHidden() + + +def test_legacy_node_v3_adapter_builds_schema() -> None: + """The adapter converts legacy metadata into a v3 schema.""" + + schema = _FakeAdapter.define_schema() + + assert schema.node_id == "SimpleSyrup.FakeAdapter" + assert schema.display_name == "Fake Adapter" + assert schema.category == "SimpleSyrup/Test" + assert schema.inputs[0].id == "text" + assert schema.inputs[0].tooltip == "Text to delegate." + assert schema.hidden[0].value == "PROMPT" + assert schema.outputs[0].id == "result" + assert schema.outputs[0].tooltip == "Delegated result." + + +def test_legacy_node_v3_adapter_delegates_execution_with_hidden_inputs() -> None: + """The adapter passes v3 visible and hidden values to the implementation.""" + + _FakeLegacyNode.calls.clear() + + assert _FakeAdapter.execute(text="value") == ("value",) + assert _FakeLegacyNode.calls == [("value", {"node": "metadata"})] diff --git a/tests/test_node_tooltips.py b/tests/test_node_tooltips.py index b3797c9..8150e48 100644 --- a/tests/test_node_tooltips.py +++ b/tests/test_node_tooltips.py @@ -2,44 +2,16 @@ # Copyright (C) 2026 Artificial Sweetener and contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""Coverage tests for ComfyUI node tooltip metadata.""" +"""Coverage tests for Comfy v3 node tooltip metadata.""" from __future__ import annotations import sys -from collections.abc import Mapping from types import ModuleType -from typing import Any, Protocol, cast +from typing import Any, Protocol import pytest -from simple_syrup.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS -from simple_syrup.nodes_v3.batch_region_conditioning import BatchRegionConditioningV3 -from simple_syrup.nodes_v3.batch_segs import BatchSEGSV3 -from simple_syrup.nodes_v3.encode_prompt_batch_with_prompt_control import ( - EncodePromptBatchWithPromptControl, -) -from simple_syrup.nodes_v3.scale_factor import ScaleFactorV3 -from simple_syrup.nodes_v3.schedule_and_encode_prompts_with_prompt_control import ( - ScheduleAndEncodePromptsWithPromptControl, -) -from simple_syrup.nodes_v3.simple_load_checkpoint import SimpleLoadCheckpointV3 -from simple_syrup.nodes_v3.tag_segs_with_wd14 import TagSEGSWithWD14V3 -from simple_syrup.nodes_v3.tile_and_tag_segs import TileAndTagSEGSV3 -from simple_syrup.nodes_v3.vae_decode_options import VAEDecodeOptionsV3 -from simple_syrup.nodes_v3.vae_encode_options import VAEEncodeOptionsV3 -from simple_syrup.nodes_v3.wd14_tagger_loader import WD14TaggerLoaderV3 - - -class _LegacyNode(Protocol): - """Protocol for legacy ComfyUI node declarations.""" - - DESCRIPTION: str - - @classmethod - def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: - """Return ComfyUI legacy input metadata.""" - class _V3Node(Protocol): """Protocol for Comfy v3 node schema declarations.""" @@ -60,6 +32,9 @@ class _FakeFolderPaths(ModuleType): self.user_directory = "E:\\ComfyUI\\user" self._files = { "checkpoints": ["model.safetensors"], + "diffusion_models": ["diffusion.safetensors"], + "text_encoders": ["text_encoder.safetensors"], + "unet": ["model.safetensors"], "vae": ["manual_vae.safetensors"], "vae_approx": [], } @@ -67,145 +42,58 @@ class _FakeFolderPaths(ModuleType): def get_filename_list(self, folder_name: str) -> list[str]: """Return deterministic filenames for a model folder.""" - return self._files[folder_name] + return self._files.get(folder_name, []) -def test_legacy_nodes_provide_tooltip_metadata() -> None: - """All exported legacy nodes expose descriptions and field-level help.""" - - for node_id, raw_node_class in NODE_CLASS_MAPPINGS.items(): - node_class = cast(_LegacyNode, raw_node_class) - assert node_id in NODE_DISPLAY_NAME_MAPPINGS - description = getattr(node_class, "DESCRIPTION", None) - assert isinstance(description, str) and description.strip(), ( - f"{node_id} is missing DESCRIPTION." - ) - - input_types = node_class.INPUT_TYPES() - assert isinstance(input_types, Mapping), f"{node_id} INPUT_TYPES is invalid." - for section_name in ("required", "optional", "hidden"): - section = input_types.get(section_name, {}) - assert isinstance(section, Mapping), ( - f"{node_id} {section_name} inputs must be a mapping." - ) - for field_name, declaration in section.items(): - if section_name == "hidden" and _legacy_hidden_sentinel(declaration): - continue - assert _tooltip_from_legacy_declaration(declaration), ( - f"{node_id} {section_name}.{field_name} is missing tooltip " - "metadata." - ) - - -def test_legacy_named_outputs_provide_tooltips() -> None: - """All named legacy outputs provide matching output tooltip metadata.""" - - for node_id, node_class in NODE_CLASS_MAPPINGS.items(): - return_names = getattr(node_class, "RETURN_NAMES", None) - if return_names is None: - continue - - output_tooltips = getattr(node_class, "OUTPUT_TOOLTIPS", None) - assert isinstance(output_tooltips, tuple), ( - f"{node_id} is missing OUTPUT_TOOLTIPS." - ) - assert len(output_tooltips) == len(return_names), ( - f"{node_id} OUTPUT_TOOLTIPS must match RETURN_NAMES length." - ) - for output_name, tooltip in zip(return_names, output_tooltips, strict=True): - assert isinstance(tooltip, str) and tooltip.strip(), ( - f"{node_id} output.{output_name} is missing tooltip metadata." - ) - - -@pytest.mark.parametrize( - "schema_class", - [ - SimpleLoadCheckpointV3, - ScaleFactorV3, - BatchSEGSV3, - BatchRegionConditioningV3, - TagSEGSWithWD14V3, - TileAndTagSEGSV3, - VAEDecodeOptionsV3, - VAEEncodeOptionsV3, - WD14TaggerLoaderV3, - EncodePromptBatchWithPromptControl, - ScheduleAndEncodePromptsWithPromptControl, - ], -) -def test_v3_nodes_provide_tooltip_metadata( - monkeypatch: pytest.MonkeyPatch, - schema_class: type[_V3Node], -) -> None: +def test_v3_nodes_provide_tooltip_metadata(monkeypatch: pytest.MonkeyPatch) -> None: """All supported v3 schemas expose descriptions and field-level help.""" monkeypatch.setitem(sys.modules, "folder_paths", _FakeFolderPaths()) + nodes_v3 = __import__("simple_syrup.nodes_v3", fromlist=["get_nodes"]) + monkeypatch.setattr(nodes_v3, "prompt_control_is_available", lambda: True) - schema = schema_class.define_schema() - assert isinstance(schema.description, str) and schema.description.strip(), ( - f"{schema.node_id} v3 schema is missing description." - ) - for input_item in schema.inputs: - tooltip = getattr(input_item, "tooltip", None) - assert isinstance(tooltip, str) and tooltip.strip(), ( - f"{schema.node_id} input.{input_item.id} is missing tooltip metadata." - ) - for output in schema.outputs: - tooltip = getattr(output, "tooltip", None) - assert isinstance(tooltip, str) and tooltip.strip(), ( - f"{schema.node_id} output.{output.id} is missing tooltip metadata." + for schema_class in nodes_v3.get_nodes(): + schema = schema_class.define_schema() + assert isinstance(schema.description, str) and schema.description.strip(), ( + f"{schema.node_id} v3 schema is missing description." ) + for input_item in schema.inputs: + tooltip = getattr(input_item, "tooltip", None) + assert isinstance(tooltip, str) and tooltip.strip(), ( + f"{schema.node_id} input.{input_item.id} is missing tooltip metadata." + ) + for output in schema.outputs: + tooltip = getattr(output, "tooltip", None) + assert isinstance(tooltip, str) and tooltip.strip(), ( + f"{schema.node_id} output.{output.id} is missing tooltip metadata." + ) -def test_high_impact_tooltips_explain_direction_and_units() -> None: +def test_high_impact_tooltips_explain_direction_and_units( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Important numeric controls explain units or practical direction.""" - detail_node = cast( - _LegacyNode, - NODE_CLASS_MAPPINGS["SimpleSyrup.DetailSEGSByScaleFactor"], - ) - detail_inputs = detail_node.INPUT_TYPES()["required"] - denoise = _tooltip_from_legacy_declaration(detail_inputs["denoise"]) - feather = _tooltip_from_legacy_declaration(detail_inputs["feather"]) - assert "lower" in denoise.lower() and "higher" in denoise.lower() - assert "pixels" in feather.lower() - - tiled_node = cast( - _LegacyNode, - NODE_CLASS_MAPPINGS["SimpleSyrup.KSamplerTiledDiffusion"], - ) - tiled_inputs = tiled_node.INPUT_TYPES()["required"] - overlap = _tooltip_from_legacy_declaration(tiled_inputs["latent_tile_overlap"]) - batch_size = _tooltip_from_legacy_declaration( - tiled_inputs["latent_tile_batch_size"] - ) - assert "overlap" in overlap.lower() and "seams" in overlap.lower() - assert "memory" in batch_size.lower() - - -def _tooltip_from_legacy_declaration(declaration: object) -> str: - """Return a legacy ComfyUI field tooltip or an empty string.""" - - if not isinstance(declaration, tuple) or len(declaration) < 2: - return "" - options = declaration[1] - if not isinstance(options, dict): - return "" - tooltip: Any = options.get("tooltip") - if not isinstance(tooltip, str): - return "" - return tooltip.strip() - - -def _legacy_hidden_sentinel(declaration: object) -> bool: - """Return whether a declaration is a Comfy legacy hidden input sentinel.""" - - return isinstance(declaration, str) and declaration in { - "PROMPT", - "DYNPROMPT", - "EXTRA_PNGINFO", - "UNIQUE_ID", - "AUTH_TOKEN_COMFY_ORG", - "API_KEY_COMFY_ORG", + monkeypatch.setitem(sys.modules, "folder_paths", _FakeFolderPaths()) + nodes_v3 = __import__("simple_syrup.nodes_v3", fromlist=["get_nodes"]) + monkeypatch.setattr(nodes_v3, "prompt_control_is_available", lambda: False) + schemas = { + node.define_schema().node_id: node.define_schema() + for node in nodes_v3.get_nodes() } + + detail_inputs = _inputs_by_id(schemas["SimpleSyrup.DetailSEGSByScaleFactor"]) + assert "lower" in detail_inputs["denoise"].tooltip.lower() + assert "higher" in detail_inputs["denoise"].tooltip.lower() + assert "pixels" in detail_inputs["feather"].tooltip.lower() + + tiled_inputs = _inputs_by_id(schemas["SimpleSyrup.KSamplerTiledDiffusion"]) + assert "overlap" in tiled_inputs["latent_tile_overlap"].tooltip.lower() + assert "seams" in tiled_inputs["latent_tile_overlap"].tooltip.lower() + assert "memory" in tiled_inputs["latent_tile_batch_size"].tooltip.lower() + + +def _inputs_by_id(schema: Any) -> dict[str, Any]: + """Return schema inputs keyed by id.""" + + return {input_item.id: input_item for input_item in schema.inputs} diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index d5f3a24..060d878 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -24,6 +24,7 @@ EXPECTED_RUNTIME_REQUIREMENTS = ( "addict", "yapf", "huggingface-hub", + "keyring", ) diff --git a/tests/test_registration.py b/tests/test_registration.py index 1ad5a75..a15eda0 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -2,7 +2,7 @@ # Copyright (C) 2026 Artificial Sweetener and contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""Tests for SimpleSyrup ComfyUI node registration.""" +"""Tests for SimpleSyrup Comfy v3-only node registration.""" from __future__ import annotations @@ -12,21 +12,80 @@ import subprocess import sys from pathlib import Path from types import ModuleType +from typing import Any, Protocol, cast import pytest -from simple_syrup.runtime.prompt_control_availability import prompt_control_is_available +BASE_NODE_IDS = [ + "SimpleSyrup.BatchRegionConditioning", + "SimpleSyrup.BatchSEGS", + "SimpleSyrup.ConditioningBatchAppend", + "SimpleSyrup.ConditioningBatchStart", + "SimpleSyrup.DetailSEGSAsRegions", + "SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion", + "SimpleSyrup.DetailSEGSByScaleFactor", + "SimpleSyrup.DetectSEGSWithUltralytics", + "SimpleSyrup.EncodePromptBatch", + "SimpleSyrup.ExternalLLMPrompt", + "SimpleSyrup.GroundedSAMModelInfo", + "SimpleSyrup.GroundingDINOModelLoader", + "SimpleSyrup.KSamplerExtras", + "SimpleSyrup.KSamplerTiledDiffusion", + "SimpleSyrup.LatentDiagnostics", + "SimpleSyrup.LayerStyleSAMModelsAdapter", + "SimpleSyrup.LoadUltralyticsModel", + "SimpleSyrup.PromptEncodeStyleAndNormalization", + "SimpleSyrup.PromptEncodeStyle", + "SimpleSyrup.PromptSEGSWithSAM", + "SimpleSyrup.ResizeImageToTarget", + "SimpleSyrup.SAMModelLoader", + "SimpleSyrup.ScaleFactor", + "SimpleSyrup.Seed", + "SimpleSyrup.SimpleLoadAnima", + "SimpleSyrup.SimpleLoadCheckpoint", + "SimpleSyrup.SimpleVAEEncode", + "SimpleSyrup.TagSEGSWithWD14", + "SimpleSyrup.TileAndTagSEGS", + "SimpleSyrup.UpscaleLatentFromImage", + "SimpleSyrup.VAEDecodeOptions", + "SimpleSyrup.VAEEncodeOptions", + "SimpleSyrup.ViTMatteModelLoader", + "SimpleSyrup.WD14TaggerLoader", +] + +PROMPT_CONTROL_NODE_IDS = [ + "SimpleSyrup.EncodePromptBatchWithPromptControl", + "SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl", +] -def test_package_exports_node_mappings() -> None: - """Root package import exposes ComfyUI mapping dictionaries.""" +class _V3Node(Protocol): + """Protocol for Comfy v3 node schema declarations.""" + + @classmethod + def define_schema(cls) -> Any: + """Return a Comfy v3 schema object.""" + + +def test_package_exports_v3_entrypoint_only() -> None: + """Root package exposes Comfy v3 registration and no legacy mappings.""" package = importlib.import_module("SimpleSyrup") - assert hasattr(package, "NODE_CLASS_MAPPINGS") - assert hasattr(package, "NODE_DISPLAY_NAME_MAPPINGS") assert hasattr(package, "comfy_entrypoint") assert package.WEB_DIRECTORY == "./web/dist" + assert not hasattr(package, "NODE_CLASS_MAPPINGS") + assert not hasattr(package, "NODE_DISPLAY_NAME_MAPPINGS") + assert package.__all__ == ["WEB_DIRECTORY", "comfy_entrypoint"] + + +def test_nodes_package_is_not_a_legacy_registry() -> None: + """The implementation package no longer owns ComfyUI registration.""" + + nodes_package = importlib.import_module("SimpleSyrup.simple_syrup.nodes") + + assert not hasattr(nodes_package, "NODE_CLASS_MAPPINGS") + assert not hasattr(nodes_package, "NODE_DISPLAY_NAME_MAPPINGS") def test_package_imports_from_custom_nodes_parent_path() -> None: @@ -41,7 +100,9 @@ def test_package_imports_from_custom_nodes_parent_path() -> None: "if pathlib.Path(p or '.').resolve() != project]; " f"sys.path.insert(0, {str(custom_nodes_root)!r}); " "package = importlib.import_module('SimpleSyrup'); " - "assert 'SimpleSyrup.PromptSEGSWithSAM' in package.NODE_CLASS_MAPPINGS; " + "assert hasattr(package, 'comfy_entrypoint'); " + "assert not hasattr(package, 'NODE_CLASS_MAPPINGS'); " + "assert not hasattr(package, 'NODE_DISPLAY_NAME_MAPPINGS'); " "assert 'server' not in sys.modules" ) @@ -85,480 +146,6 @@ def test_comfy_import_exposes_stable_internal_package_alias() -> None: assert result.returncode == 0, result.stderr -def test_resize_node_is_registered() -> None: - """Resize node id maps to the expected node class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.ResizeImageToTarget"] - - assert registered.__name__ == "ResizeImageToTarget" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.ResizeImageToTarget"] - == "Resize Image to Target" - ) - - -def test_ksampler_extras_node_is_registered() -> None: - """KSampler Extras node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.KSamplerExtras"] - - assert registered.__name__ == "KSamplerExtras" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.KSamplerExtras"] - == "KSampler (Extras)" - ) - - -def test_ksampler_tiled_diffusion_node_is_registered() -> None: - """KSampler tiled diffusion node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.KSamplerTiledDiffusion"] - - assert registered.__name__ == "KSamplerTiledDiffusion" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.KSamplerTiledDiffusion"] - == "KSampler (Tiled Diffusion)" - ) - assert ( - "KSamplerTiledDiffusion" - in importlib.import_module("SimpleSyrup.simple_syrup.nodes").__all__ - ) - assert "SimpleSyrup.KSamplerMixtureOfDiffusers" not in package.NODE_CLASS_MAPPINGS - assert "SimpleSyrup.KSamplerMultiDiffusion" not in package.NODE_CLASS_MAPPINGS - assert ( - "SimpleSyrup.KSamplerMixtureOfDiffusers" - not in package.NODE_DISPLAY_NAME_MAPPINGS - ) - assert ( - "SimpleSyrup.KSamplerMultiDiffusion" not in package.NODE_DISPLAY_NAME_MAPPINGS - ) - - -def test_batch_segs_node_is_registered() -> None: - """Batch SEGS node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.BatchSEGS"] - - assert registered.__name__ == "BatchSEGS" - assert package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.BatchSEGS"] == "Batch SEGS" - - -def test_batch_region_conditioning_node_is_registered() -> None: - """Batch Region Conditioning node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.BatchRegionConditioning"] - - assert registered.__name__ == "BatchRegionConditioning" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.BatchRegionConditioning"] - == "Batch Region Conditioning" - ) - - -def test_latent_diagnostics_node_is_registered() -> None: - """Latent Diagnostics node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.LatentDiagnostics"] - - assert registered.__name__ == "LatentDiagnostics" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.LatentDiagnostics"] - == "Latent Diagnostics" - ) - - -def test_prompt_encode_style_node_is_registered() -> None: - """Prompt Encode Style node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.PromptEncodeStyle"] - - assert registered.__name__ == "PromptEncodeStyle" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.PromptEncodeStyle"] - == "Prompt Encode Style" - ) - - -def test_prompt_encode_style_and_normalization_node_is_registered() -> None: - """Prompt Encode Style & Normalization node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS[ - "SimpleSyrup.PromptEncodeStyleAndNormalization" - ] - - assert registered.__name__ == "PromptEncodeStyleAndNormalization" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS[ - "SimpleSyrup.PromptEncodeStyleAndNormalization" - ] - == "Prompt Encode Style & Normalization" - ) - - -def test_prompt_control_encode_style_clean_break_id_is_removed() -> None: - """Old Prompt Control Encode Style node id is not registered.""" - - package = importlib.import_module("SimpleSyrup") - - assert "SimpleSyrup.PromptControlEncodeStyle" not in package.NODE_CLASS_MAPPINGS - assert ( - "SimpleSyrup.PromptControlEncodeStyle" not in package.NODE_DISPLAY_NAME_MAPPINGS - ) - - -def test_prompt_control_schedule_node_is_legacy_registered_when_available() -> None: - """Prompt-Control schedule node is exported through legacy mappings.""" - - if not prompt_control_is_available(): - pytest.skip("Prompt-Control is not installed in this test environment.") - - package = importlib.import_module("SimpleSyrup") - nodes_module = importlib.import_module("SimpleSyrup.simple_syrup.nodes") - - registered = package.NODE_CLASS_MAPPINGS[ - "SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl" - ] - assert registered.__name__ == "ScheduleAndEncodePromptsWithPromptControl" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS[ - "SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl" - ] - == "Schedule & Encode Prompts" - ) - assert "ScheduleAndEncodePromptsWithPromptControl" in nodes_module.__all__ - - -def test_prompt_segs_with_sam_node_is_registered() -> None: - """Prompt SEGS w/ SAM node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.PromptSEGSWithSAM"] - - assert registered.__name__ == "PromptSEGSWithSAM" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.PromptSEGSWithSAM"] - == "Prompt SEGS w/ SAM" - ) - assert "SimpleSyrup.PromptSAMMask" not in package.NODE_CLASS_MAPPINGS - assert "SimpleSyrup.PromptSAMMask" not in package.NODE_DISPLAY_NAME_MAPPINGS - - -def test_sam_model_loader_node_is_registered() -> None: - """SAM loader node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.SAMModelLoader"] - - assert registered.__name__ == "SAMModelLoader" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.SAMModelLoader"] - == "SAM Model Loader" - ) - - -def test_scale_factor_node_is_registered() -> None: - """Scale Factor node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.ScaleFactor"] - - assert registered.__name__ == "ScaleFactor" - assert package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.ScaleFactor"] == ( - "Scale Factor" - ) - assert ( - "ScaleFactor" - in importlib.import_module("SimpleSyrup.simple_syrup.nodes").__all__ - ) - - -def test_seed_node_is_registered() -> None: - """Seed node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.Seed"] - - assert registered.__name__ == "Seed" - assert package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.Seed"] == "Seed" - - -def test_grounding_dino_model_loader_node_is_registered() -> None: - """GroundingDINO loader node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.GroundingDINOModelLoader"] - - assert registered.__name__ == "GroundingDINOModelLoader" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.GroundingDINOModelLoader"] - == "GroundingDINO Model Loader" - ) - - -def test_vitmatte_model_loader_node_is_registered() -> None: - """ViTMatte loader node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.ViTMatteModelLoader"] - - assert registered.__name__ == "ViTMatteModelLoader" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.ViTMatteModelLoader"] - == "ViTMatte Model Loader" - ) - - -def test_wd14_tagger_loader_node_is_registered() -> None: - """WD14 tagger loader node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.WD14TaggerLoader"] - - assert registered.__name__ == "WD14TaggerLoader" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.WD14TaggerLoader"] - == "Load WD14 Tagger" - ) - - -def test_load_ultralytics_model_node_is_registered() -> None: - """Load Ultralytics Model node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.LoadUltralyticsModel"] - - assert registered.__name__ == "LoadUltralyticsModel" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.LoadUltralyticsModel"] - == "Load Ultralytics Model" - ) - - -def test_detect_segs_with_ultralytics_node_is_registered() -> None: - """Detect SEGS w/ Ultralytics node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.DetectSEGSWithUltralytics"] - - assert registered.__name__ == "DetectSEGSWithUltralytics" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.DetectSEGSWithUltralytics"] - == "Detect SEGS w/ Ultralytics" - ) - - -def test_detail_segs_by_scale_factor_node_is_registered() -> None: - """Detail SEGS by Scale Factor node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.DetailSEGSByScaleFactor"] - - assert registered.__name__ == "DetailSEGSByScaleFactor" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.DetailSEGSByScaleFactor"] - == "Detail SEGS by Scale Factor" - ) - - -def test_tiled_detail_segs_by_scale_factor_node_is_registered() -> None: - """Tiled Detail SEGS by Scale Factor node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS[ - "SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion" - ] - - assert registered.__name__ == "DetailSEGSByScaleFactorTiledDiffusion" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS[ - "SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion" - ] - == "Detail SEGS by Scale Factor w/ Tiled Diffusion" - ) - assert ( - "DetailSEGSByScaleFactorTiledDiffusion" - in importlib.import_module("SimpleSyrup.simple_syrup.nodes").__all__ - ) - - -def test_detail_segs_as_regions_node_is_registered() -> None: - """Detail SEGS as Regions node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.DetailSEGSAsRegions"] - - assert registered.__name__ == "DetailSEGSAsRegions" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.DetailSEGSAsRegions"] - == "Detail SEGS as Regions" - ) - - -def test_tile_and_tag_segs_node_is_registered() -> None: - """Tile & Tag SEGS node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.TileAndTagSEGS"] - - assert registered.__name__ == "TileAndTagSEGS" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.TileAndTagSEGS"] - == "Tile & Tag SEGS" - ) - - -def test_tag_segs_with_wd14_node_is_registered() -> None: - """Tag SEGS w/ WD14 node maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.TagSEGSWithWD14"] - - assert registered.__name__ == "TagSEGSWithWD14" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.TagSEGSWithWD14"] - == "Tag SEGS w/ WD14" - ) - - -def test_conditioning_batch_nodes_are_registered() -> None: - """Conditioning batch nodes map to their classes and display names.""" - - package = importlib.import_module("SimpleSyrup") - - assert ( - package.NODE_CLASS_MAPPINGS["SimpleSyrup.ConditioningBatchStart"].__name__ - == "ConditioningBatchStart" - ) - assert ( - package.NODE_CLASS_MAPPINGS["SimpleSyrup.ConditioningBatchAppend"].__name__ - == "ConditioningBatchAppend" - ) - assert ( - package.NODE_CLASS_MAPPINGS["SimpleSyrup.EncodePromptBatch"].__name__ - == "EncodePromptBatch" - ) - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.ConditioningBatchStart"] - == "Conditioning Batch Start" - ) - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.ConditioningBatchAppend"] - == "Conditioning Batch Append" - ) - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.EncodePromptBatch"] - == "Encode Prompt Batch" - ) - - -def test_layerstyle_adapter_node_is_registered() -> None: - """LayerStyle adapter node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.LayerStyleSAMModelsAdapter"] - - assert registered.__name__ == "LayerStyleSAMModelsAdapter" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.LayerStyleSAMModelsAdapter"] - == "LayerStyle SAM Models Adapter" - ) - - -def test_grounded_sam_model_info_node_is_registered() -> None: - """Grounded SAM model info node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.GroundedSAMModelInfo"] - - assert registered.__name__ == "GroundedSAMModelInfo" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.GroundedSAMModelInfo"] - == "Grounded SAM Model Info" - ) - - -def test_simple_load_anima_node_is_registered() -> None: - """Simple Load Anima node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.SimpleLoadAnima"] - - assert registered.__name__ == "SimpleLoadAnima" - assert registered.RETURN_TYPES == ("MODEL", "CLIP", "VAE") - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.SimpleLoadAnima"] - == "Simple Load Anima" - ) - - -def test_simple_load_checkpoint_node_is_registered() -> None: - """Simple Load Checkpoint node id maps to its class and display name.""" - - package = importlib.import_module("SimpleSyrup") - registered = package.NODE_CLASS_MAPPINGS["SimpleSyrup.SimpleLoadCheckpoint"] - - assert registered.__name__ == "SimpleLoadCheckpoint" - assert registered.RETURN_TYPES == ("MODEL", "CLIP", "VAE") - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.SimpleLoadCheckpoint"] - == "Simple Load Checkpoint" - ) - - -def test_provenance_latent_nodes_are_registered() -> None: - """Provenance-aware latent nodes map to their classes and display names.""" - - package = importlib.import_module("SimpleSyrup") - nodes_package = importlib.import_module("SimpleSyrup.simple_syrup.nodes") - - simple_vae = package.NODE_CLASS_MAPPINGS["SimpleSyrup.SimpleVAEEncode"] - upscale = package.NODE_CLASS_MAPPINGS["SimpleSyrup.UpscaleLatentFromImage"] - - assert simple_vae.__name__ == "SimpleVAEEncode" - assert upscale.__name__ == "UpscaleLatentFromImage" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.SimpleVAEEncode"] - == "Simple VAE Encode" - ) - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.UpscaleLatentFromImage"] - == "Upscale Latent From Image" - ) - assert "SimpleVAEEncode" in nodes_package.__all__ - assert "UpscaleLatentFromImage" in nodes_package.__all__ - - -def test_vae_options_nodes_are_registered() -> None: - """VAE options nodes map to their classes and display names.""" - - package = importlib.import_module("SimpleSyrup") - nodes_package = importlib.import_module("SimpleSyrup.simple_syrup.nodes") - - encode = package.NODE_CLASS_MAPPINGS["SimpleSyrup.VAEEncodeOptions"] - decode = package.NODE_CLASS_MAPPINGS["SimpleSyrup.VAEDecodeOptions"] - - assert encode.__name__ == "VAEEncodeOptions" - assert decode.__name__ == "VAEDecodeOptions" - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.VAEEncodeOptions"] - == "VAE Encode (Options)" - ) - assert ( - package.NODE_DISPLAY_NAME_MAPPINGS["SimpleSyrup.VAEDecodeOptions"] - == "VAE Decode (Options)" - ) - assert "VAEEncodeOptions" in nodes_package.__all__ - assert "VAEDecodeOptions" in nodes_package.__all__ - - def test_registration_import_does_not_require_torchlanc() -> None: """Importing registration does not eagerly import TorchLanc.""" @@ -569,39 +156,10 @@ def test_registration_import_does_not_require_torchlanc() -> None: assert imported_module is None -def test_v3_entrypoint_registers_tile_and_prompt_control_batch_nodes( +def test_v3_entrypoint_exports_all_base_nodes_without_prompt_control( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Comfy v3 entrypoint exposes native v3 nodes without Prompt Control imports.""" - - sys.modules.pop("prompt_control.nodes_lazy", None) - package = importlib.import_module("SimpleSyrup") - nodes_v3 = importlib.import_module("SimpleSyrup.simple_syrup.nodes_v3") - monkeypatch.setattr(nodes_v3, "prompt_control_is_available", lambda: True) - - extension = asyncio.run(package.comfy_entrypoint()) - nodes = asyncio.run(extension.get_node_list()) - - assert [node.__name__ for node in nodes] == [ - "WD14TaggerLoaderV3", - "BatchSEGSV3", - "BatchRegionConditioningV3", - "TagSEGSWithWD14V3", - "TileAndTagSEGSV3", - "SimpleLoadCheckpointV3", - "ScaleFactorV3", - "VAEDecodeOptionsV3", - "VAEEncodeOptionsV3", - "EncodePromptBatchWithPromptControl", - "ScheduleAndEncodePromptsWithPromptControl", - ] - assert "prompt_control.nodes_lazy" not in sys.modules - - -def test_v3_entrypoint_keeps_tile_node_when_prompt_control_unavailable( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Comfy v3 entrypoint omits only Prompt Control nodes when unavailable.""" + """Comfy v3 entrypoint exports every maintained non-conditional node.""" sys.modules.pop("prompt_control.nodes_lazy", None) package = importlib.import_module("SimpleSyrup") @@ -611,15 +169,38 @@ def test_v3_entrypoint_keeps_tile_node_when_prompt_control_unavailable( extension = asyncio.run(package.comfy_entrypoint()) nodes = asyncio.run(extension.get_node_list()) - assert [node.__name__ for node in nodes] == [ - "WD14TaggerLoaderV3", - "BatchSEGSV3", - "BatchRegionConditioningV3", - "TagSEGSWithWD14V3", - "TileAndTagSEGSV3", - "SimpleLoadCheckpointV3", - "ScaleFactorV3", - "VAEDecodeOptionsV3", - "VAEEncodeOptionsV3", + assert _node_ids(cast(list[type[_V3Node]], nodes)) == BASE_NODE_IDS + assert "prompt_control.nodes_lazy" not in sys.modules + + +def test_v3_entrypoint_adds_only_prompt_control_nodes_when_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prompt Control availability adds conditional nodes without removing others.""" + + sys.modules.pop("prompt_control.nodes_lazy", None) + package = importlib.import_module("SimpleSyrup") + nodes_v3 = importlib.import_module("SimpleSyrup.simple_syrup.nodes_v3") + monkeypatch.setattr(nodes_v3, "prompt_control_is_available", lambda: True) + + extension = asyncio.run(package.comfy_entrypoint()) + nodes = asyncio.run(extension.get_node_list()) + + assert _node_ids(cast(list[type[_V3Node]], nodes)) == [ + *BASE_NODE_IDS, + *PROMPT_CONTROL_NODE_IDS, ] assert "prompt_control.nodes_lazy" not in sys.modules + + +def _node_ids(nodes: list[type[_V3Node]]) -> list[str]: + """Return node ids from v3 schemas.""" + + ids: list[str] = [] + for node in nodes: + schema = node.define_schema() + node_id: Any = schema.node_id + if not isinstance(node_id, str): + raise AssertionError(f"{node.__name__} has invalid node_id.") + ids.append(node_id) + return ids diff --git a/tests/test_settings.py b/tests/test_settings.py index 03990d8..bef5775 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -78,7 +78,12 @@ def test_saving_settings_writes_validated_schema(tmp_path: Path) -> None: repository.save(SimpleSyrupSettings(show_downloadable_models=False)) assert json.loads(path.read_text(encoding="utf-8")) == { - "show_downloadable_models": False + "external_llm": { + "base_url": "", + "cached_models": [], + "default_model": "", + }, + "show_downloadable_models": False, } @@ -100,3 +105,71 @@ def test_payload_validation_rejects_non_boolean_value() -> None: with pytest.raises(SimpleSyrupSettingsError, match="show_downloadable_models"): SimpleSyrupSettings.from_payload({"show_downloadable_models": 1}) + + +def test_missing_external_llm_settings_loads_defaults(tmp_path: Path) -> None: + """Existing settings files without external LLM settings remain valid.""" + + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"show_downloadable_models": False}), + encoding="utf-8", + ) + + settings = SimpleSyrupSettingsRepository(path).load() + + assert settings.show_downloadable_models is False + assert settings.external_llm.base_url == "" + assert settings.external_llm.cached_models == () + assert settings.external_llm.default_model == "" + + +def test_valid_external_llm_settings_are_loaded(tmp_path: Path) -> None: + """External LLM settings are normalized when loaded.""" + + path = tmp_path / "settings.json" + path.write_text( + json.dumps( + { + "show_downloadable_models": True, + "external_llm": { + "base_url": "https://provider.example/v1/", + "cached_models": ["model-a", "model-a", "model-b"], + "default_model": "model-b", + }, + } + ), + encoding="utf-8", + ) + + settings = SimpleSyrupSettingsRepository(path).load() + + assert settings.external_llm.base_url == "https://provider.example/v1" + assert settings.external_llm.cached_models == ("model-a", "model-b") + assert settings.external_llm.default_model == "model-b" + + +def test_invalid_external_llm_settings_fall_back_to_external_defaults( + tmp_path: Path, +) -> None: + """Malformed external LLM settings do not invalidate other settings.""" + + path = tmp_path / "settings.json" + path.write_text( + json.dumps( + { + "show_downloadable_models": False, + "external_llm": { + "base_url": "not-a-url", + "cached_models": ["model-a"], + "default_model": "model-a", + }, + } + ), + encoding="utf-8", + ) + + settings = SimpleSyrupSettingsRepository(path).load() + + assert settings.show_downloadable_models is False + assert settings.external_llm.base_url == "" diff --git a/tests/test_settings_routes.py b/tests/test_settings_routes.py index 914957d..1d152ad 100644 --- a/tests/test_settings_routes.py +++ b/tests/test_settings_routes.py @@ -17,6 +17,7 @@ from aiohttp import web import simple_syrup.runtime.settings_routes as settings_routes from simple_syrup.runtime.settings import ( + ExternalLLMSettings, SimpleSyrupSettings, SimpleSyrupSettingsRepository, ) @@ -114,7 +115,14 @@ def test_get_settings_returns_current_settings(tmp_path: Path) -> None: response = asyncio.run(prompt_server.routes.get_handlers[SETTINGS_ROUTE](object())) assert response.status == 200 - assert json.loads(response_text(response)) == {"show_downloadable_models": False} + assert json.loads(response_text(response)) == { + "external_llm": { + "base_url": "", + "cached_models": [], + "default_model": "", + }, + "show_downloadable_models": False, + } def test_post_settings_validates_and_persists_payload(tmp_path: Path) -> None: @@ -131,10 +139,57 @@ def test_post_settings_validates_and_persists_payload(tmp_path: Path) -> None: ) assert response.status == 200 - assert json.loads(response_text(response)) == {"show_downloadable_models": False} + assert json.loads(response_text(response)) == { + "external_llm": { + "base_url": "", + "cached_models": [], + "default_model": "", + }, + "show_downloadable_models": False, + } assert repository.load().show_downloadable_models is False +def test_post_settings_preserves_external_llm_when_payload_omits_it( + tmp_path: Path, +) -> None: + """Generic settings updates do not clear saved external LLM config.""" + + prompt_server = FakePromptServer() + repository = SimpleSyrupSettingsRepository(tmp_path / "settings.json") + repository.save( + SimpleSyrupSettings( + show_downloadable_models=True, + external_llm=ExternalLLMSettings( + base_url="https://provider.example/v1", + cached_models=("model-a",), + default_model="model-a", + ), + ) + ) + register_fake_routes(repository, prompt_server) + + response = asyncio.run( + prompt_server.routes.post_handlers[SETTINGS_ROUTE]( + FakeRequest({"show_downloadable_models": False}) + ) + ) + + payload = json.loads(response_text(response)) + assert response.status == 200 + assert payload == { + "external_llm": { + "base_url": "https://provider.example/v1", + "cached_models": ["model-a"], + "default_model": "model-a", + }, + "show_downloadable_models": False, + } + loaded = repository.load() + assert loaded.show_downloadable_models is False + assert loaded.external_llm.base_url == "https://provider.example/v1" + + def test_post_settings_rejects_non_boolean_payload(tmp_path: Path) -> None: """POST rejects malformed setting values.""" diff --git a/web/dist/simple-syrup.js b/web/dist/simple-syrup.js index 30a2579..9579688 100644 --- a/web/dist/simple-syrup.js +++ b/web/dist/simple-syrup.js @@ -3,11 +3,17 @@ import { app } from "../../../scripts/app.js"; // web/src/api.ts var SETTINGS_ROUTE = "/simple-syrup/settings"; +var EXTERNAL_LLM_SETTINGS_ROUTE = "/simple-syrup/external-llm/settings"; +var EXTERNAL_LLM_API_KEY_ROUTE = "/simple-syrup/external-llm/api-key"; +var EXTERNAL_LLM_MODELS_REFRESH_ROUTE = "/simple-syrup/external-llm/models/refresh"; async function getSettings(fetchImpl = fetch) { const response = await fetchImpl(SETTINGS_ROUTE); if (!response.ok) { throw new Error( - `Could not load SimpleSyrup settings. Backend returned ${String(response.status)}.` + await backendErrorMessage( + response, + `Could not load SimpleSyrup settings. Backend returned ${String(response.status)}.` + ) ); } return parseSettings(await response.json()); @@ -20,7 +26,10 @@ async function saveSettings(settings, fetchImpl = fetch) { }); if (!response.ok) { throw new Error( - `Could not save SimpleSyrup settings. Backend returned ${String(response.status)}.` + await backendErrorMessage( + response, + `Could not save SimpleSyrup settings. Backend returned ${String(response.status)}.` + ) ); } return parseSettings(await response.json()); @@ -35,19 +44,124 @@ function parseSettings(payload) { show_downloadable_models: payload.show_downloadable_models }; } +async function getExternalLLMSettings(fetchImpl = fetch) { + const response = await fetchImpl(EXTERNAL_LLM_SETTINGS_ROUTE); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not load external LLM settings. Backend returned ${String(response.status)}.` + ) + ); + } + return parseExternalLLMSettings(await response.json()); +} +async function saveExternalLLMSettings(settings, fetchImpl = fetch) { + const response = await fetchImpl(EXTERNAL_LLM_SETTINGS_ROUTE, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings) + }); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not save external LLM settings. Backend returned ${String(response.status)}.` + ) + ); + } + return parseExternalLLMSettings(await response.json()); +} +async function saveExternalLLMApiKey(payload, fetchImpl = fetch) { + const response = await fetchImpl(EXTERNAL_LLM_API_KEY_ROUTE, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not save external LLM API key. Backend returned ${String(response.status)}.` + ) + ); + } + return parseExternalLLMSettings(await response.json()); +} +async function refreshExternalLLMModels(fetchImpl = fetch) { + const response = await fetchImpl(EXTERNAL_LLM_MODELS_REFRESH_ROUTE, { + method: "POST" + }); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not refresh external LLM models. Backend returned ${String(response.status)}.` + ) + ); + } + return parseExternalLLMSettings(await response.json()); +} +function parseExternalLLMSettings(payload) { + if (!isExternalLLMSettingsPayload(payload)) { + throw new Error( + "External LLM settings payload is invalid. Expected base_url, cached_models, default_model, and has_api_key." + ); + } + return { + base_url: payload.base_url, + cached_models: [...payload.cached_models], + default_model: payload.default_model, + has_api_key: payload.has_api_key + }; +} function isSettingsPayload(payload) { return typeof payload === "object" && payload !== null && typeof payload.show_downloadable_models === "boolean"; } +function isExternalLLMSettingsPayload(payload) { + return typeof payload === "object" && payload !== null && typeof payload.base_url === "string" && Array.isArray(payload.cached_models) && payload.cached_models?.every( + (model) => typeof model === "string" + ) === true && typeof payload.default_model === "string" && typeof payload.has_api_key === "boolean"; +} +async function backendErrorMessage(response, fallback) { + try { + const payload = await response.json(); + if (typeof payload === "object" && payload !== null && typeof payload.error === "string") { + return payload.error; + } + } catch { + return fallback; + } + return fallback; +} // web/src/settings.ts var SIMPLE_SYRUP_SETTING_ID = "SimpleSyrup.ShowDownloadableModels"; var SIMPLE_SYRUP_SETTING_LABEL = "SimpleSyrup: Show downloadable models in loader dropdowns"; var SIMPLE_SYRUP_SETTING_DESCRIPTION = "Show known downloadable SAM, GroundingDINO, and ViTMatte models even when they are not installed locally."; +var EXTERNAL_LLM_ENDPOINT_SETTING_ID = "SimpleSyrup.ExternalLLM.Endpoint"; +var EXTERNAL_LLM_ENDPOINT_SETTING_LABEL = "SimpleSyrup: External LLM endpoint"; +var EXTERNAL_LLM_ENDPOINT_SETTING_DESCRIPTION = "OpenAI-compatible endpoint base URL used by SimpleSyrup prompt nodes."; +var EXTERNAL_LLM_API_KEY_SETTING_ID = "SimpleSyrup.ExternalLLM.ApiKey"; +var EXTERNAL_LLM_API_KEY_SETTING_LABEL = "SimpleSyrup: External LLM API key"; +var EXTERNAL_LLM_API_KEY_SETTING_DESCRIPTION = "Stores the API key for the configured external LLM endpoint in OS credential storage."; var DEFAULT_SETTINGS = { show_downloadable_models: true }; -async function registerSimpleSyrupSettings(app2, api = { getSettings, saveSettings }, logger = console) { +async function registerSimpleSyrupSettings(app2, api = { + getSettings, + saveSettings, + getExternalLLMSettings, + saveExternalLLMSettings, + saveExternalLLMApiKey +}, logger = console) { let initialSettings = DEFAULT_SETTINGS; + let externalLLMSettings = { + base_url: "", + cached_models: [], + default_model: "", + has_api_key: false + }; try { initialSettings = await api.getSettings(); } catch (error) { @@ -56,7 +170,17 @@ async function registerSimpleSyrupSettings(app2, api = { getSettings, saveSettin error ); } + try { + externalLLMSettings = await api.getExternalLLMSettings(); + } catch (error) { + logger.warn( + "Could not load SimpleSyrup external LLM settings. Using empty endpoint settings until the backend is available.", + error + ); + } let savedSettings = initialSettings; + let savedExternalLLMSettings = externalLLMSettings; + installSimpleSyrupSettingsStyle(); const setting = app2.ui.settings.addSetting({ id: SIMPLE_SYRUP_SETTING_ID, name: SIMPLE_SYRUP_SETTING_LABEL, @@ -80,6 +204,310 @@ async function registerSimpleSyrupSettings(app2, api = { getSettings, saveSettin } }); setting.value = initialSettings.show_downloadable_models; + app2.ui.settings.addSetting({ + id: EXTERNAL_LLM_ENDPOINT_SETTING_ID, + name: EXTERNAL_LLM_ENDPOINT_SETTING_LABEL, + sortOrder: 320, + type: () => createExternalLLMEndpointControl({ + api, + logger, + refreshModelChoices: () => refreshExternalLLMModelChoices(app2, logger), + getSettings: () => savedExternalLLMSettings, + setSettings: (settings) => { + savedExternalLLMSettings = settings; + } + }), + defaultValue: externalLLMSettings.base_url, + tooltip: EXTERNAL_LLM_ENDPOINT_SETTING_DESCRIPTION + }); + app2.ui.settings.addSetting({ + id: EXTERNAL_LLM_API_KEY_SETTING_ID, + name: EXTERNAL_LLM_API_KEY_SETTING_LABEL, + sortOrder: 319, + type: () => createExternalLLMApiKeyControl({ + api, + logger, + refreshModelChoices: () => refreshExternalLLMModelChoices(app2, logger), + getSettings: () => savedExternalLLMSettings, + setSettings: (settings) => { + savedExternalLLMSettings = settings; + } + }), + defaultValue: "", + tooltip: EXTERNAL_LLM_API_KEY_SETTING_DESCRIPTION + }); +} +function endpointShouldBeSaved(value) { + const endpoint = value.trim(); + if (!endpoint) { + return true; + } + try { + const parsed = new URL(endpoint); + return (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.hostname.length > 0; + } catch { + return false; + } +} +function installSimpleSyrupSettingsStyle() { + if (document.getElementById("simple-syrup-settings-style")) { + return; + } + const style = document.createElement("style"); + style.id = "simple-syrup-settings-style"; + style.textContent = ` + .simple-syrup-settings-row { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: min(38rem, 100%); + } + .simple-syrup-settings-row[data-pending="true"] { + opacity: 0.75; + } + .simple-syrup-settings-input { + min-width: 16rem; + flex: 1 1 auto; + } + .simple-syrup-settings-button { + flex: 0 0 auto; + white-space: nowrap; + } + .simple-syrup-settings-status { + color: var(--fg-color); + opacity: 0.8; + white-space: normal; + overflow-wrap: anywhere; + } + .simple-syrup-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 2147483647; + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 45%); + } + .simple-syrup-dialog { + display: grid; + gap: 0.75rem; + min-width: min(28rem, calc(100vw - 2rem)); + padding: 1rem; + background: var(--comfy-menu-bg); + color: var(--fg-color); + } + .simple-syrup-dialog-title { + margin: 0; + font-size: 1rem; + } + .simple-syrup-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + `; + document.head.appendChild(style); +} +function createExternalLLMEndpointControl(context) { + const wrapper = createElement("div", "simple-syrup-settings-row"); + const input = createElement("input", "simple-syrup-settings-input"); + input.type = "text"; + input.value = context.getSettings().base_url; + input.autocomplete = "off"; + const saveButton = createElement("button", "simple-syrup-settings-button"); + saveButton.type = "button"; + saveButton.textContent = "Save Endpoint"; + const status = createElement("span", "simple-syrup-settings-status"); + const saveEndpoint = async () => { + const value = input.value.trim(); + if (!endpointShouldBeSaved(value)) { + status.textContent = "Enter an http:// or https:// endpoint."; + return; + } + setPending(wrapper, true); + try { + const saved = await context.api.saveExternalLLMSettings({ + base_url: value, + default_model: context.getSettings().default_model + }); + context.setSettings(saved); + input.value = saved.base_url; + await context.refreshModelChoices(); + status.textContent = "Endpoint saved."; + } catch (error) { + context.logger.warn( + "Could not save SimpleSyrup external LLM endpoint settings. The backend rejected the setting update.", + error + ); + status.textContent = errorMessage(error, "Endpoint was not saved."); + } finally { + setPending(wrapper, false); + } + }; + saveButton.addEventListener("click", () => { + void saveEndpoint(); + }); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void saveEndpoint(); + } + }); + wrapper.append(input, saveButton, status); + return wrapper; +} +function createExternalLLMApiKeyControl(context) { + const wrapper = createElement("div", "simple-syrup-settings-row"); + const button = createElement("button", "simple-syrup-settings-button"); + button.type = "button"; + const status = createElement("span", "simple-syrup-settings-status"); + const render = () => { + const remembered = context.getSettings().has_api_key; + button.textContent = remembered ? "Replace API Key" : "Add API Key"; + status.textContent = remembered ? "API key remembered." : ""; + }; + button.addEventListener("click", () => { + if (!context.getSettings().base_url.trim()) { + status.textContent = "Save endpoint first."; + return; + } + openExternalLLMApiKeyDialog({ + replacing: context.getSettings().has_api_key, + onSubmit: async (apiKey) => { + setPending(wrapper, true); + try { + const saved = await context.api.saveExternalLLMApiKey({ + api_key: apiKey + }); + context.setSettings(saved); + await context.refreshModelChoices(); + render(); + status.textContent = context.getSettings().has_api_key ? "API key remembered." : "API key was not saved."; + } catch (error) { + context.logger.warn( + "Could not save SimpleSyrup external LLM API key. The backend rejected the credential update.", + error + ); + status.textContent = apiKeyErrorMessage(error); + } finally { + setPending(wrapper, false); + } + } + }); + }); + wrapper.append(button, status); + render(); + return wrapper; +} +function openExternalLLMApiKeyDialog(options) { + const overlay = createElement("div", "simple-syrup-dialog-backdrop"); + const dialog = createElement("div", "simple-syrup-dialog comfy-dialog"); + const title = createElement("h3", "simple-syrup-dialog-title"); + title.textContent = options.replacing ? "Replace API Key" : "Add API Key"; + const input = createElement("input", "simple-syrup-settings-input"); + input.type = "password"; + input.autocomplete = "off"; + input.placeholder = "API key"; + input.setAttribute("data-1p-ignore", "true"); + input.setAttribute("data-lpignore", "true"); + input.setAttribute("data-bwignore", "true"); + const actions = createElement("div", "simple-syrup-dialog-actions"); + const submitButton = createElement("button", "simple-syrup-settings-button"); + submitButton.type = "button"; + submitButton.textContent = options.replacing ? "Replace Key" : "Store Key"; + const cancelButton = createElement("button", "simple-syrup-settings-button"); + cancelButton.type = "button"; + cancelButton.textContent = "Cancel"; + const close = () => { + overlay.remove(); + }; + const submit = async () => { + const apiKey = input.value.trim(); + if (!apiKey) { + input.focus(); + return; + } + submitButton.disabled = true; + await options.onSubmit(apiKey); + close(); + }; + submitButton.addEventListener("click", () => { + void submit(); + }); + cancelButton.addEventListener("click", close); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submit(); + } + if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }); + actions.append(submitButton, cancelButton); + dialog.append(title, input, actions); + overlay.append(dialog); + document.body.appendChild(overlay); + input.focus(); +} +function createElement(tagName, className) { + const element = document.createElement(tagName); + element.className = className; + return element; +} +function setPending(element, pending) { + element.dataset.pending = pending ? "true" : "false"; + for (const control of Array.from(element.querySelectorAll("input, button"))) { + if (control instanceof HTMLInputElement || control instanceof HTMLButtonElement) { + control.disabled = pending; + } + } +} +function errorMessage(error, fallback) { + if (error instanceof Error && error.message) { + return error.message; + } + return fallback; +} +function apiKeyErrorMessage(error) { + const message = errorMessage(error, "API key was not saved."); + if (message.includes("Configure an external LLM endpoint")) { + return "Save endpoint first."; + } + return message; +} +async function refreshExternalLLMModelChoices(app2, logger) { + try { + await app2.refreshComboInNodes?.(); + } catch (error) { + logger.warn( + "Could not refresh Comfy node definitions after saving external LLM settings.", + error + ); + } +} + +// web/src/refresh.ts +var REFRESH_WRAPPED = /* @__PURE__ */ Symbol.for("SimpleSyrup.ExternalLLM.RefreshWrapped"); +function registerExternalLLMRefreshHook(app2, api = { refreshExternalLLMModels }, logger = console) { + if (!app2.refreshComboInNodes) { + return; + } + const refreshOwner = app2; + if (refreshOwner[REFRESH_WRAPPED]) { + return; + } + const originalRefresh = app2.refreshComboInNodes.bind(app2); + refreshOwner[REFRESH_WRAPPED] = true; + app2.refreshComboInNodes = async () => { + try { + await api.refreshExternalLLMModels(); + } catch (error) { + logger.warn("Could not refresh SimpleSyrup external LLM models.", error); + } + await originalRefresh(); + }; } // web/src/main.ts @@ -88,5 +516,6 @@ comfyApp.registerExtension({ name: "SimpleSyrup.Settings", async setup(appInstance) { await registerSimpleSyrupSettings(appInstance); + registerExternalLLMRefreshHook(appInstance); } }); diff --git a/web/src/api.ts b/web/src/api.ts index ab7f064..7dc44c8 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -6,12 +6,32 @@ export interface SimpleSyrupSettings { show_downloadable_models: boolean; } +export interface ExternalLLMSettings { + base_url: string; + cached_models: string[]; + default_model: string; + has_api_key: boolean; +} + +export interface ExternalLLMSettingsUpdate { + base_url: string; + default_model: string; +} + +export interface ExternalLLMApiKeyUpdate { + api_key: string; +} + export type FetchLike = ( input: RequestInfo | URL, init?: RequestInit ) => Promise; const SETTINGS_ROUTE = "/simple-syrup/settings"; +const EXTERNAL_LLM_SETTINGS_ROUTE = "/simple-syrup/external-llm/settings"; +const EXTERNAL_LLM_API_KEY_ROUTE = "/simple-syrup/external-llm/api-key"; +const EXTERNAL_LLM_MODELS_REFRESH_ROUTE = + "/simple-syrup/external-llm/models/refresh"; export async function getSettings( fetchImpl: FetchLike = fetch @@ -19,7 +39,10 @@ export async function getSettings( const response = await fetchImpl(SETTINGS_ROUTE); if (!response.ok) { throw new Error( - `Could not load SimpleSyrup settings. Backend returned ${String(response.status)}.` + await backendErrorMessage( + response, + `Could not load SimpleSyrup settings. Backend returned ${String(response.status)}.` + ) ); } return parseSettings(await response.json()); @@ -36,7 +59,10 @@ export async function saveSettings( }); if (!response.ok) { throw new Error( - `Could not save SimpleSyrup settings. Backend returned ${String(response.status)}.` + await backendErrorMessage( + response, + `Could not save SimpleSyrup settings. Backend returned ${String(response.status)}.` + ) ); } return parseSettings(await response.json()); @@ -53,6 +79,111 @@ export function parseSettings(payload: unknown): SimpleSyrupSettings { }; } +export async function getExternalLLMSettings( + fetchImpl: FetchLike = fetch +): Promise { + const response = await fetchImpl(EXTERNAL_LLM_SETTINGS_ROUTE); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not load external LLM settings. Backend returned ${String(response.status)}.` + ) + ); + } + return parseExternalLLMSettings(await response.json()); +} + +export async function saveExternalLLMSettings( + settings: ExternalLLMSettingsUpdate, + fetchImpl: FetchLike = fetch +): Promise { + const response = await fetchImpl(EXTERNAL_LLM_SETTINGS_ROUTE, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings) + }); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not save external LLM settings. Backend returned ${String(response.status)}.` + ) + ); + } + return parseExternalLLMSettings(await response.json()); +} + +export async function saveExternalLLMApiKey( + payload: ExternalLLMApiKeyUpdate, + fetchImpl: FetchLike = fetch +): Promise { + const response = await fetchImpl(EXTERNAL_LLM_API_KEY_ROUTE, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not save external LLM API key. Backend returned ${String(response.status)}.` + ) + ); + } + return parseExternalLLMSettings(await response.json()); +} + +export async function deleteExternalLLMApiKey( + fetchImpl: FetchLike = fetch +): Promise { + const response = await fetchImpl(EXTERNAL_LLM_API_KEY_ROUTE, { + method: "DELETE" + }); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not delete external LLM API key. Backend returned ${String(response.status)}.` + ) + ); + } + return parseExternalLLMSettings(await response.json()); +} + +export async function refreshExternalLLMModels( + fetchImpl: FetchLike = fetch +): Promise { + const response = await fetchImpl(EXTERNAL_LLM_MODELS_REFRESH_ROUTE, { + method: "POST" + }); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not refresh external LLM models. Backend returned ${String(response.status)}.` + ) + ); + } + return parseExternalLLMSettings(await response.json()); +} + +export function parseExternalLLMSettings( + payload: unknown +): ExternalLLMSettings { + if (!isExternalLLMSettingsPayload(payload)) { + throw new Error( + "External LLM settings payload is invalid. Expected base_url, cached_models, default_model, and has_api_key." + ); + } + return { + base_url: payload.base_url, + cached_models: [...payload.cached_models], + default_model: payload.default_model, + has_api_key: payload.has_api_key + }; +} + function isSettingsPayload(payload: unknown): payload is SimpleSyrupSettings { return ( typeof payload === "object" && @@ -61,3 +192,39 @@ function isSettingsPayload(payload: unknown): payload is SimpleSyrupSettings { "boolean" ); } + +function isExternalLLMSettingsPayload( + payload: unknown +): payload is ExternalLLMSettings { + return ( + typeof payload === "object" && + payload !== null && + typeof (payload as Partial).base_url === "string" && + Array.isArray((payload as Partial).cached_models) && + (payload as Partial).cached_models?.every( + (model) => typeof model === "string" + ) === true && + typeof (payload as Partial).default_model === + "string" && + typeof (payload as Partial).has_api_key === "boolean" + ); +} + +async function backendErrorMessage( + response: Response, + fallback: string +): Promise { + try { + const payload: unknown = await response.json(); + if ( + typeof payload === "object" && + payload !== null && + typeof (payload as { error?: unknown }).error === "string" + ) { + return (payload as { error: string }).error; + } + } catch { + return fallback; + } + return fallback; +} diff --git a/web/src/main.ts b/web/src/main.ts index 18e9591..30c66d1 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -6,6 +6,7 @@ import { app } from "../../../scripts/app.js"; import { registerSimpleSyrupSettings } from "./settings"; +import { registerExternalLLMRefreshHook } from "./refresh"; import type { ComfyApp } from "./types"; const comfyApp = app as unknown as ComfyApp; @@ -14,5 +15,6 @@ comfyApp.registerExtension({ name: "SimpleSyrup.Settings", async setup(appInstance) { await registerSimpleSyrupSettings(appInstance); + registerExternalLLMRefreshHook(appInstance); } }); diff --git a/web/src/refresh.ts b/web/src/refresh.ts new file mode 100644 index 0000000..9857f23 --- /dev/null +++ b/web/src/refresh.ts @@ -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 + +import { refreshExternalLLMModels } from "./api"; +import type { ComfyApp, Logger } from "./types"; + +const REFRESH_WRAPPED = Symbol.for("SimpleSyrup.ExternalLLM.RefreshWrapped"); + +interface RefreshOwner { + [REFRESH_WRAPPED]?: boolean; +} + +export interface ExternalLLMRefreshApi { + refreshExternalLLMModels(): Promise; +} + +export function registerExternalLLMRefreshHook( + app: ComfyApp, + api: ExternalLLMRefreshApi = { refreshExternalLLMModels }, + logger: Logger = console +): void { + if (!app.refreshComboInNodes) { + return; + } + + const refreshOwner = app as RefreshOwner; + if (refreshOwner[REFRESH_WRAPPED]) { + return; + } + + const originalRefresh = app.refreshComboInNodes.bind(app); + refreshOwner[REFRESH_WRAPPED] = true; + app.refreshComboInNodes = async () => { + try { + await api.refreshExternalLLMModels(); + } catch (error) { + logger.warn("Could not refresh SimpleSyrup external LLM models.", error); + } + await originalRefresh(); + }; +} diff --git a/web/src/settings.ts b/web/src/settings.ts index f62fcb5..91d429a 100644 --- a/web/src/settings.ts +++ b/web/src/settings.ts @@ -2,8 +2,14 @@ // Copyright (C) 2026 Artificial Sweetener and contributors // SPDX-License-Identifier: AGPL-3.0-or-later -import { getSettings, saveSettings } from "./api"; -import type { SimpleSyrupSettings } from "./api"; +import { + getExternalLLMSettings, + getSettings, + saveExternalLLMApiKey, + saveExternalLLMSettings, + saveSettings +} from "./api"; +import type { ExternalLLMSettings, SimpleSyrupSettings } from "./api"; import type { ComfyApp, Logger } from "./types"; export const SIMPLE_SYRUP_SETTING_ID = "SimpleSyrup.ShowDownloadableModels"; @@ -11,6 +17,18 @@ export const SIMPLE_SYRUP_SETTING_LABEL = "SimpleSyrup: Show downloadable models in loader dropdowns"; export const SIMPLE_SYRUP_SETTING_DESCRIPTION = "Show known downloadable SAM, GroundingDINO, and ViTMatte models even when they are not installed locally."; +export const EXTERNAL_LLM_ENDPOINT_SETTING_ID = + "SimpleSyrup.ExternalLLM.Endpoint"; +export const EXTERNAL_LLM_ENDPOINT_SETTING_LABEL = + "SimpleSyrup: External LLM endpoint"; +export const EXTERNAL_LLM_ENDPOINT_SETTING_DESCRIPTION = + "OpenAI-compatible endpoint base URL used by SimpleSyrup prompt nodes."; +export const EXTERNAL_LLM_API_KEY_SETTING_ID = + "SimpleSyrup.ExternalLLM.ApiKey"; +export const EXTERNAL_LLM_API_KEY_SETTING_LABEL = + "SimpleSyrup: External LLM API key"; +export const EXTERNAL_LLM_API_KEY_SETTING_DESCRIPTION = + "Stores the API key for the configured external LLM endpoint in OS credential storage."; const DEFAULT_SETTINGS: SimpleSyrupSettings = { show_downloadable_models: true @@ -19,14 +37,31 @@ const DEFAULT_SETTINGS: SimpleSyrupSettings = { export interface SimpleSyrupSettingsApi { getSettings(): Promise; saveSettings(settings: SimpleSyrupSettings): Promise; + getExternalLLMSettings(): Promise; + saveExternalLLMSettings( + settings: Pick + ): Promise; + saveExternalLLMApiKey(settings: { api_key: string }): Promise; } export async function registerSimpleSyrupSettings( app: ComfyApp, - api: SimpleSyrupSettingsApi = { getSettings, saveSettings }, + api: SimpleSyrupSettingsApi = { + getSettings, + saveSettings, + getExternalLLMSettings, + saveExternalLLMSettings, + saveExternalLLMApiKey + }, logger: Logger = console ): Promise { let initialSettings = DEFAULT_SETTINGS; + let externalLLMSettings: ExternalLLMSettings = { + base_url: "", + cached_models: [], + default_model: "", + has_api_key: false + }; try { initialSettings = await api.getSettings(); @@ -36,7 +71,17 @@ export async function registerSimpleSyrupSettings( error ); } + try { + externalLLMSettings = await api.getExternalLLMSettings(); + } catch (error) { + logger.warn( + "Could not load SimpleSyrup external LLM settings. Using empty endpoint settings until the backend is available.", + error + ); + } let savedSettings = initialSettings; + let savedExternalLLMSettings = externalLLMSettings; + installSimpleSyrupSettingsStyle(); const setting = app.ui.settings.addSetting({ id: SIMPLE_SYRUP_SETTING_ID, @@ -62,4 +107,348 @@ export async function registerSimpleSyrupSettings( }); setting.value = initialSettings.show_downloadable_models; + + app.ui.settings.addSetting({ + id: EXTERNAL_LLM_ENDPOINT_SETTING_ID, + name: EXTERNAL_LLM_ENDPOINT_SETTING_LABEL, + sortOrder: 320, + type: () => + createExternalLLMEndpointControl({ + api, + logger, + refreshModelChoices: () => + refreshExternalLLMModelChoices(app, logger), + getSettings: () => savedExternalLLMSettings, + setSettings: (settings) => { + savedExternalLLMSettings = settings; + } + }), + defaultValue: externalLLMSettings.base_url, + tooltip: EXTERNAL_LLM_ENDPOINT_SETTING_DESCRIPTION + }); + + app.ui.settings.addSetting({ + id: EXTERNAL_LLM_API_KEY_SETTING_ID, + name: EXTERNAL_LLM_API_KEY_SETTING_LABEL, + sortOrder: 319, + type: () => + createExternalLLMApiKeyControl({ + api, + logger, + refreshModelChoices: () => + refreshExternalLLMModelChoices(app, logger), + getSettings: () => savedExternalLLMSettings, + setSettings: (settings) => { + savedExternalLLMSettings = settings; + } + }), + defaultValue: "", + tooltip: EXTERNAL_LLM_API_KEY_SETTING_DESCRIPTION + }); +} + +function endpointShouldBeSaved(value: string): boolean { + const endpoint = value.trim(); + if (!endpoint) { + return true; + } + try { + const parsed = new URL(endpoint); + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + parsed.hostname.length > 0 + ); + } catch { + return false; + } +} + +function installSimpleSyrupSettingsStyle(): void { + if (document.getElementById("simple-syrup-settings-style")) { + return; + } + + const style = document.createElement("style"); + style.id = "simple-syrup-settings-style"; + style.textContent = ` + .simple-syrup-settings-row { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: min(38rem, 100%); + } + .simple-syrup-settings-row[data-pending="true"] { + opacity: 0.75; + } + .simple-syrup-settings-input { + min-width: 16rem; + flex: 1 1 auto; + } + .simple-syrup-settings-button { + flex: 0 0 auto; + white-space: nowrap; + } + .simple-syrup-settings-status { + color: var(--fg-color); + opacity: 0.8; + white-space: normal; + overflow-wrap: anywhere; + } + .simple-syrup-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 2147483647; + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 45%); + } + .simple-syrup-dialog { + display: grid; + gap: 0.75rem; + min-width: min(28rem, calc(100vw - 2rem)); + padding: 1rem; + background: var(--comfy-menu-bg); + color: var(--fg-color); + } + .simple-syrup-dialog-title { + margin: 0; + font-size: 1rem; + } + .simple-syrup-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + `; + document.head.appendChild(style); +} + +interface ExternalLLMControlContext { + api: SimpleSyrupSettingsApi; + logger: Logger; + refreshModelChoices(): Promise; + getSettings(): ExternalLLMSettings; + setSettings(settings: ExternalLLMSettings): void; +} + +function createExternalLLMEndpointControl( + context: ExternalLLMControlContext +): HTMLElement { + const wrapper = createElement("div", "simple-syrup-settings-row"); + const input = createElement("input", "simple-syrup-settings-input"); + input.type = "text"; + input.value = context.getSettings().base_url; + input.autocomplete = "off"; + + const saveButton = createElement("button", "simple-syrup-settings-button"); + saveButton.type = "button"; + saveButton.textContent = "Save Endpoint"; + + const status = createElement("span", "simple-syrup-settings-status"); + + const saveEndpoint = async (): Promise => { + const value = input.value.trim(); + if (!endpointShouldBeSaved(value)) { + status.textContent = "Enter an http:// or https:// endpoint."; + return; + } + + setPending(wrapper, true); + try { + const saved = await context.api.saveExternalLLMSettings({ + base_url: value, + default_model: context.getSettings().default_model + }); + context.setSettings(saved); + input.value = saved.base_url; + await context.refreshModelChoices(); + status.textContent = "Endpoint saved."; + } catch (error) { + context.logger.warn( + "Could not save SimpleSyrup external LLM endpoint settings. The backend rejected the setting update.", + error + ); + status.textContent = errorMessage(error, "Endpoint was not saved."); + } finally { + setPending(wrapper, false); + } + }; + + saveButton.addEventListener("click", () => { + void saveEndpoint(); + }); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void saveEndpoint(); + } + }); + + wrapper.append(input, saveButton, status); + return wrapper; +} + +function createExternalLLMApiKeyControl( + context: ExternalLLMControlContext +): HTMLElement { + const wrapper = createElement("div", "simple-syrup-settings-row"); + const button = createElement("button", "simple-syrup-settings-button"); + button.type = "button"; + const status = createElement("span", "simple-syrup-settings-status"); + + const render = (): void => { + const remembered = context.getSettings().has_api_key; + button.textContent = remembered ? "Replace API Key" : "Add API Key"; + status.textContent = remembered ? "API key remembered." : ""; + }; + + button.addEventListener("click", () => { + if (!context.getSettings().base_url.trim()) { + status.textContent = "Save endpoint first."; + return; + } + + openExternalLLMApiKeyDialog({ + replacing: context.getSettings().has_api_key, + onSubmit: async (apiKey) => { + setPending(wrapper, true); + try { + const saved = await context.api.saveExternalLLMApiKey({ + api_key: apiKey + }); + context.setSettings(saved); + await context.refreshModelChoices(); + render(); + status.textContent = context.getSettings().has_api_key + ? "API key remembered." + : "API key was not saved."; + } catch (error) { + context.logger.warn( + "Could not save SimpleSyrup external LLM API key. The backend rejected the credential update.", + error + ); + status.textContent = apiKeyErrorMessage(error); + } finally { + setPending(wrapper, false); + } + } + }); + }); + + wrapper.append(button, status); + render(); + return wrapper; +} + +function openExternalLLMApiKeyDialog(options: { + replacing: boolean; + onSubmit(apiKey: string): Promise; +}): void { + const overlay = createElement("div", "simple-syrup-dialog-backdrop"); + const dialog = createElement("div", "simple-syrup-dialog comfy-dialog"); + const title = createElement("h3", "simple-syrup-dialog-title"); + title.textContent = options.replacing ? "Replace API Key" : "Add API Key"; + + const input = createElement("input", "simple-syrup-settings-input"); + input.type = "password"; + input.autocomplete = "off"; + input.placeholder = "API key"; + input.setAttribute("data-1p-ignore", "true"); + input.setAttribute("data-lpignore", "true"); + input.setAttribute("data-bwignore", "true"); + + const actions = createElement("div", "simple-syrup-dialog-actions"); + const submitButton = createElement("button", "simple-syrup-settings-button"); + submitButton.type = "button"; + submitButton.textContent = options.replacing ? "Replace Key" : "Store Key"; + const cancelButton = createElement("button", "simple-syrup-settings-button"); + cancelButton.type = "button"; + cancelButton.textContent = "Cancel"; + + const close = (): void => { + overlay.remove(); + }; + + const submit = async (): Promise => { + const apiKey = input.value.trim(); + if (!apiKey) { + input.focus(); + return; + } + submitButton.disabled = true; + await options.onSubmit(apiKey); + close(); + }; + + submitButton.addEventListener("click", () => { + void submit(); + }); + cancelButton.addEventListener("click", close); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submit(); + } + if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }); + + actions.append(submitButton, cancelButton); + dialog.append(title, input, actions); + overlay.append(dialog); + document.body.appendChild(overlay); + input.focus(); +} + +function createElement( + tagName: TTag, + className: string +): HTMLElementTagNameMap[TTag] { + const element = document.createElement(tagName); + element.className = className; + return element; +} + +function setPending(element: HTMLElement, pending: boolean): void { + element.dataset.pending = pending ? "true" : "false"; + for (const control of Array.from(element.querySelectorAll("input, button"))) { + if ( + control instanceof HTMLInputElement || + control instanceof HTMLButtonElement + ) { + control.disabled = pending; + } + } +} + +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) { + return error.message; + } + return fallback; +} + +function apiKeyErrorMessage(error: unknown): string { + const message = errorMessage(error, "API key was not saved."); + if (message.includes("Configure an external LLM endpoint")) { + return "Save endpoint first."; + } + return message; +} + +async function refreshExternalLLMModelChoices( + app: ComfyApp, + logger: Logger +): Promise { + try { + await app.refreshComboInNodes?.(); + } catch (error) { + logger.warn( + "Could not refresh Comfy node definitions after saving external LLM settings.", + error + ); + } } diff --git a/web/src/types.ts b/web/src/types.ts index abcbf01..4eb8286 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -13,8 +13,9 @@ export interface ComfySetting { export interface ComfySettingDefinition { id: string; name: string; - type: "boolean"; + type: "boolean" | "text" | (() => HTMLElement); defaultValue: TValue; + sortOrder?: number; tooltip?: string; onChange?: (value: TValue) => void | Promise; } @@ -29,6 +30,7 @@ export interface ComfyApp { ui: { settings: ComfySettingsApi; }; + refreshComboInNodes?: () => Promise; registerExtension(extension: ComfyExtension): void; } diff --git a/web/tests/api.test.ts b/web/tests/api.test.ts index 899a2c5..12d6d4a 100644 --- a/web/tests/api.test.ts +++ b/web/tests/api.test.ts @@ -4,7 +4,17 @@ import { describe, expect, it, vi } from "vitest"; -import { getSettings, parseSettings, saveSettings } from "../src/api"; +import { + deleteExternalLLMApiKey, + getExternalLLMSettings, + getSettings, + parseExternalLLMSettings, + parseSettings, + refreshExternalLLMModels, + saveExternalLLMApiKey, + saveExternalLLMSettings, + saveSettings +} from "../src/api"; import type { FetchLike } from "../src/api"; import { createJsonResponse } from "./testUtils"; @@ -38,24 +48,22 @@ describe("settings API", () => { ); }); - it("rejects non-OK load responses with an actionable error", async () => { + it("surfaces backend load errors when available", async () => { const fetchImpl = vi .fn() .mockResolvedValue(createJsonResponse({ error: "nope" }, { status: 500 })); - await expect(getSettings(fetchImpl)).rejects.toThrow( - "Could not load SimpleSyrup settings" - ); + await expect(getSettings(fetchImpl)).rejects.toThrow("nope"); }); - it("rejects non-OK save responses with an actionable error", async () => { + it("surfaces backend save errors when available", async () => { const fetchImpl = vi .fn() .mockResolvedValue(createJsonResponse({ error: "nope" }, { status: 400 })); await expect( saveSettings({ show_downloadable_models: false }, fetchImpl) - ).rejects.toThrow("Could not save SimpleSyrup settings"); + ).rejects.toThrow("nope"); }); it("rejects invalid response payloads conservatively", () => { @@ -64,3 +72,102 @@ describe("settings API", () => { ); }); }); + +describe("external LLM settings API", () => { + const payload = { + base_url: "https://provider.example/v1", + cached_models: ["model-a"], + default_model: "model-a", + has_api_key: true + }; + + it("loads external LLM settings from the backend route", async () => { + const fetchImpl = vi.fn().mockResolvedValue(createJsonResponse(payload)); + + await expect(getExternalLLMSettings(fetchImpl)).resolves.toEqual(payload); + expect(fetchImpl).toHaveBeenCalledWith( + "/simple-syrup/external-llm/settings" + ); + }); + + it("saves external LLM endpoint settings", async () => { + const fetchImpl = vi.fn().mockResolvedValue(createJsonResponse(payload)); + + await expect( + saveExternalLLMSettings( + { + base_url: "https://provider.example/v1", + default_model: "model-a" + }, + fetchImpl + ) + ).resolves.toEqual(payload); + expect(fetchImpl).toHaveBeenCalledWith( + "/simple-syrup/external-llm/settings", + expect.objectContaining({ method: "POST" }) + ); + }); + + it("saves external LLM API keys without expecting the secret back", async () => { + const fetchImpl = vi.fn().mockResolvedValue(createJsonResponse(payload)); + + await expect( + saveExternalLLMApiKey({ api_key: "secret" }, fetchImpl) + ).resolves.toEqual(payload); + expect(fetchImpl).toHaveBeenCalledWith( + "/simple-syrup/external-llm/api-key", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ api_key: "secret" }) + }) + ); + }); + + it("surfaces backend API key save errors", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + createJsonResponse( + { error: "Configure an external LLM endpoint first." }, + { status: 400 } + ) + ); + + await expect( + saveExternalLLMApiKey({ api_key: "secret" }, fetchImpl) + ).rejects.toThrow("Configure an external LLM endpoint first."); + }); + + it("deletes external LLM API keys", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + createJsonResponse({ ...payload, has_api_key: false }) + ); + + await expect(deleteExternalLLMApiKey(fetchImpl)).resolves.toMatchObject({ + has_api_key: false + }); + expect(fetchImpl).toHaveBeenCalledWith( + "/simple-syrup/external-llm/api-key", + { method: "DELETE" } + ); + }); + + it("refreshes external LLM models", async () => { + const fetchImpl = vi.fn().mockResolvedValue(createJsonResponse(payload)); + + await expect(refreshExternalLLMModels(fetchImpl)).resolves.toEqual(payload); + expect(fetchImpl).toHaveBeenCalledWith( + "/simple-syrup/external-llm/models/refresh", + { method: "POST" } + ); + }); + + it("rejects malformed external LLM settings payloads", () => { + expect(() => + parseExternalLLMSettings({ + base_url: "https://provider.example/v1", + cached_models: [1], + default_model: "model-a", + has_api_key: true + }) + ).toThrow("External LLM settings payload is invalid"); + }); +}); diff --git a/web/tests/refresh.test.ts b/web/tests/refresh.test.ts new file mode 100644 index 0000000..2e8b1dc --- /dev/null +++ b/web/tests/refresh.test.ts @@ -0,0 +1,64 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from "vitest"; + +import { registerExternalLLMRefreshHook } from "../src/refresh"; +import { createFakeComfyApp } from "./testUtils"; + +describe("external LLM refresh hook", () => { + it("refreshes external models before Comfy refreshes node definitions", async () => { + const app = createFakeComfyApp(); + const calls: string[] = []; + app.refreshComboInNodes = vi.fn().mockImplementation(() => { + calls.push("comfy"); + return Promise.resolve(); + }); + const api = { + refreshExternalLLMModels: vi.fn().mockImplementation(() => { + calls.push("external"); + return Promise.resolve(); + }) + }; + + registerExternalLLMRefreshHook(app, api); + await app.refreshComboInNodes(); + + expect(calls).toEqual(["external", "comfy"]); + }); + + it("still runs the original refresh when external refresh fails", async () => { + const app = createFakeComfyApp(); + const logger = { warn: vi.fn() }; + const originalRefresh = vi.fn().mockResolvedValue(undefined); + app.refreshComboInNodes = originalRefresh; + + registerExternalLLMRefreshHook( + app, + { refreshExternalLLMModels: vi.fn().mockRejectedValue(new Error("offline")) }, + logger + ); + await app.refreshComboInNodes(); + + expect(originalRefresh).toHaveBeenCalledOnce(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Could not refresh"), + expect.any(Error) + ); + }); + + it("wraps the refresh function only once", async () => { + const app = createFakeComfyApp(); + const originalRefresh = vi.fn().mockResolvedValue(undefined); + app.refreshComboInNodes = originalRefresh; + const api = { refreshExternalLLMModels: vi.fn().mockResolvedValue(undefined) }; + + registerExternalLLMRefreshHook(app, api); + registerExternalLLMRefreshHook(app, api); + await app.refreshComboInNodes(); + + expect(api.refreshExternalLLMModels).toHaveBeenCalledOnce(); + expect(originalRefresh).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/tests/settings.test.ts b/web/tests/settings.test.ts index 16266a3..56036ed 100644 --- a/web/tests/settings.test.ts +++ b/web/tests/settings.test.ts @@ -7,6 +7,8 @@ import { describe, expect, it, vi } from "vitest"; import { SIMPLE_SYRUP_SETTING_ID, SIMPLE_SYRUP_SETTING_LABEL, + EXTERNAL_LLM_API_KEY_SETTING_ID, + EXTERNAL_LLM_ENDPOINT_SETTING_ID, registerSimpleSyrupSettings } from "../src/settings"; import type { SimpleSyrupSettingsApi } from "../src/settings"; @@ -19,7 +21,7 @@ describe("Comfy settings registration", () => { await registerSimpleSyrupSettings(app, api); - expect(app.ui.settings.definitions).toHaveLength(1); + expect(app.ui.settings.definitions).toHaveLength(3); expect(app.ui.settings.definitions[0]).toMatchObject({ id: SIMPLE_SYRUP_SETTING_ID, name: SIMPLE_SYRUP_SETTING_LABEL, @@ -27,6 +29,16 @@ describe("Comfy settings registration", () => { defaultValue: false }); expect(app.ui.settings.settings[0]?.value).toBe(false); + expect(app.ui.settings.definitions[1]).toMatchObject({ + id: EXTERNAL_LLM_ENDPOINT_SETTING_ID, + sortOrder: 320 + }); + expect(typeof app.ui.settings.definitions[1]?.type).toBe("function"); + expect(app.ui.settings.definitions[2]).toMatchObject({ + id: EXTERNAL_LLM_API_KEY_SETTING_ID, + sortOrder: 319 + }); + expect(typeof app.ui.settings.definitions[2]?.type).toBe("function"); }); it("saves setting changes to the backend", async () => { @@ -36,7 +48,10 @@ describe("Comfy settings registration", () => { .mockResolvedValue({ show_downloadable_models: true }); const api: SimpleSyrupSettingsApi = { getSettings: vi.fn().mockResolvedValue({ show_downloadable_models: false }), - saveSettings + saveSettings, + getExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()), + saveExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()), + saveExternalLLMApiKey: vi.fn().mockResolvedValue(defaultExternalLLMSettings()) }; await registerSimpleSyrupSettings(app, api); @@ -53,7 +68,10 @@ describe("Comfy settings registration", () => { const logger = { warn: vi.fn() }; const api: SimpleSyrupSettingsApi = { getSettings: vi.fn().mockRejectedValue(new Error("offline")), - saveSettings: vi.fn().mockResolvedValue({ show_downloadable_models: true }) + saveSettings: vi.fn().mockResolvedValue({ show_downloadable_models: true }), + getExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()), + saveExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()), + saveExternalLLMApiKey: vi.fn().mockResolvedValue(defaultExternalLLMSettings()) }; await registerSimpleSyrupSettings(app, api, logger); @@ -74,7 +92,10 @@ describe("Comfy settings registration", () => { .mockRejectedValueOnce(new Error("rejected")); const api: SimpleSyrupSettingsApi = { getSettings: vi.fn().mockResolvedValue({ show_downloadable_models: false }), - saveSettings + saveSettings, + getExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()), + saveExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()), + saveExternalLLMApiKey: vi.fn().mockResolvedValue(defaultExternalLLMSettings()) }; await registerSimpleSyrupSettings(app, api, logger); @@ -87,6 +108,185 @@ describe("Comfy settings registration", () => { ); expect(app.ui.settings.settings[0]?.value).toBe(true); }); + + it("saves external LLM endpoint changes to the backend", async () => { + const app = createFakeComfyApp(); + const refreshComboInNodes = vi.fn().mockResolvedValue(undefined); + app.refreshComboInNodes = refreshComboInNodes; + const saveExternalLLMSettings = + vi.fn().mockResolvedValue({ + ...defaultExternalLLMSettings(), + base_url: "https://provider.example/v1" + }); + const api = fakeSettingsApi(true); + api.saveExternalLLMSettings = saveExternalLLMSettings; + + await registerSimpleSyrupSettings(app, api); + const control = renderSetting(getDefinition(app, 1)); + const input = requiredInput(control, "input"); + const button = requiredButton(control, "button"); + + input.value = "https://provider.example/v1"; + button.click(); + await flushPromises(); + + expect(saveExternalLLMSettings).toHaveBeenCalledWith({ + base_url: "https://provider.example/v1", + default_model: "" + }); + expect(refreshComboInNodes).toHaveBeenCalledOnce(); + expect(control.textContent).toContain("Endpoint saved."); + }); + + it("keeps partial endpoint text visible while the user is typing", async () => { + const app = createFakeComfyApp(); + const saveExternalLLMSettings = + vi.fn(); + const api = fakeSettingsApi(true); + api.saveExternalLLMSettings = saveExternalLLMSettings; + + await registerSimpleSyrupSettings(app, api); + const control = renderSetting(getDefinition(app, 1)); + const input = requiredInput(control, "input"); + const button = requiredButton(control, "button"); + + input.value = "https://"; + button.click(); + await Promise.resolve(); + + expect(saveExternalLLMSettings).not.toHaveBeenCalled(); + expect(input.value).toBe("https://"); + }); + + it("saves API keys through an add dialog without showing the stored key", async () => { + const app = createFakeComfyApp(); + const refreshComboInNodes = vi.fn().mockResolvedValue(undefined); + app.refreshComboInNodes = refreshComboInNodes; + const saveExternalLLMApiKey = + vi.fn().mockResolvedValue({ + ...configuredExternalLLMSettings(), + has_api_key: true + }); + const api = fakeSettingsApi(true); + api.getExternalLLMSettings = vi + .fn() + .mockResolvedValue(configuredExternalLLMSettings()); + api.saveExternalLLMApiKey = saveExternalLLMApiKey; + + await registerSimpleSyrupSettings(app, api); + const control = renderSetting(getDefinition(app, 2)); + const addButton = requiredButton(control, "button"); + expect(addButton.textContent).toBe("Add API Key"); + + addButton.click(); + const dialogInput = requiredInput(document.body, ".simple-syrup-dialog input"); + const submitButton = requiredButton( + document.body, + ".simple-syrup-dialog button" + ); + + dialogInput.value = "secret"; + submitButton.click(); + await flushPromises(); + + expect(saveExternalLLMApiKey).toHaveBeenCalledWith({ api_key: "secret" }); + expect(refreshComboInNodes).toHaveBeenCalledOnce(); + expect(document.body.querySelector(".simple-syrup-dialog")).toBeNull(); + expect(control.textContent).toContain("API key remembered."); + expect(control.textContent).not.toContain("secret"); + }); + + it("keeps save success visible if model choice refresh fails", async () => { + const app = createFakeComfyApp(); + const logger = { warn: vi.fn() }; + app.refreshComboInNodes = vi.fn().mockRejectedValue(new Error("offline")); + const saveExternalLLMSettings = + vi.fn().mockResolvedValue({ + ...defaultExternalLLMSettings(), + base_url: "https://provider.example/v1" + }); + const api = fakeSettingsApi(true); + api.saveExternalLLMSettings = saveExternalLLMSettings; + + await registerSimpleSyrupSettings(app, api, logger); + const control = renderSetting(getDefinition(app, 1)); + const input = requiredInput(control, "input"); + const button = requiredButton(control, "button"); + + input.value = "https://provider.example/v1"; + button.click(); + await flushPromises(); + + expect(control.textContent).toContain("Endpoint saved."); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Could not refresh Comfy node definitions"), + expect.any(Error) + ); + }); + + it("requires a saved endpoint before opening the API key dialog", async () => { + const app = createFakeComfyApp(); + const api = fakeSettingsApi(true); + const saveExternalLLMApiKey = vi.fn< + SimpleSyrupSettingsApi["saveExternalLLMApiKey"] + >(); + api.saveExternalLLMApiKey = saveExternalLLMApiKey; + + await registerSimpleSyrupSettings(app, api); + const control = renderSetting(getDefinition(app, 2)); + const addButton = requiredButton(control, "button"); + + addButton.click(); + + expect(document.body.querySelector(".simple-syrup-dialog")).toBeNull(); + expect(control.textContent).toContain("Save endpoint first."); + expect(saveExternalLLMApiKey).not.toHaveBeenCalled(); + }); + + it("shows backend API key save errors in the setting row", async () => { + const app = createFakeComfyApp(); + const api = fakeSettingsApi(true); + api.getExternalLLMSettings = vi + .fn() + .mockResolvedValue(configuredExternalLLMSettings()); + api.saveExternalLLMApiKey = vi + .fn() + .mockRejectedValue(new Error("Configure an external LLM endpoint first.")); + + await registerSimpleSyrupSettings(app, api); + const control = renderSetting(getDefinition(app, 2)); + const addButton = requiredButton(control, "button"); + + addButton.click(); + const dialogInput = requiredInput(document.body, ".simple-syrup-dialog input"); + const submitButton = requiredButton( + document.body, + ".simple-syrup-dialog button" + ); + + dialogInput.value = "secret"; + submitButton.click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(control.textContent).toContain("Save endpoint first."); + expect(control.textContent).not.toContain("secret"); + }); + + it("offers replacement when an API key is remembered", async () => { + const app = createFakeComfyApp(); + const api = fakeSettingsApi(true); + api.getExternalLLMSettings = vi + .fn() + .mockResolvedValue({ ...defaultExternalLLMSettings(), has_api_key: true }); + + await registerSimpleSyrupSettings(app, api); + const control = renderSetting(getDefinition(app, 2)); + const button = control.querySelector("button"); + + expect(button?.textContent).toBe("Replace API Key"); + expect(control.textContent).toContain("API key remembered."); + }); }); function fakeSettingsApi( @@ -96,6 +296,68 @@ function fakeSettingsApi( getSettings: vi.fn().mockResolvedValue({ show_downloadable_models: showDownloadableModels }), - saveSettings: vi.fn().mockImplementation((settings) => Promise.resolve(settings)) + saveSettings: vi.fn().mockImplementation((settings) => Promise.resolve(settings)), + getExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()), + saveExternalLLMSettings: vi + .fn() + .mockImplementation((settings) => + Promise.resolve({ ...defaultExternalLLMSettings(), ...settings }) + ), + saveExternalLLMApiKey: vi.fn().mockResolvedValue(defaultExternalLLMSettings()) }; } + +function defaultExternalLLMSettings() { + return { + base_url: "", + cached_models: [], + default_model: "", + has_api_key: false + }; +} + +function configuredExternalLLMSettings() { + return { + base_url: "https://provider.example/v1", + cached_models: [], + default_model: "", + has_api_key: false + }; +} + +function renderSetting(definition: { + type: "boolean" | "text" | (() => HTMLElement); +}): HTMLElement { + expect(typeof definition.type).toBe("function"); + return (definition.type as () => HTMLElement)(); +} + +function getDefinition(app: ReturnType, index: number) { + const definition = app.ui.settings.definitions[index]; + if (!definition) { + throw new Error(`Expected setting definition at index ${String(index)}.`); + } + return definition; +} + +function requiredInput(parent: ParentNode, selector: string): HTMLInputElement { + const input = parent.querySelector(selector); + if (!(input instanceof HTMLInputElement)) { + throw new Error(`Expected input for selector ${selector}.`); + } + return input; +} + +function requiredButton(parent: ParentNode, selector: string): HTMLButtonElement { + const button = parent.querySelector(selector); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button for selector ${selector}.`); + } + return button; +} + +async function flushPromises(): Promise { + for (let index = 0; index < 6; index += 1) { + await Promise.resolve(); + } +} diff --git a/web/tests/testUtils.ts b/web/tests/testUtils.ts index d8673d2..528356a 100644 --- a/web/tests/testUtils.ts +++ b/web/tests/testUtils.ts @@ -11,8 +11,8 @@ import type { } from "../src/types"; export interface FakeComfySettingsApi { - definitions: Array>; - settings: Array>; + definitions: Array>; + settings: Array>; addSetting( definition: ComfySettingDefinition ): ComfySetting; @@ -36,9 +36,9 @@ export function createFakeComfyApp(): FakeComfyApp { value: definition.defaultValue }; this.definitions.push( - definition as unknown as ComfySettingDefinition + definition as unknown as ComfySettingDefinition ); - this.settings.push(setting as ComfySetting); + this.settings.push(setting); return setting; } }; @@ -48,6 +48,7 @@ export function createFakeComfyApp(): FakeComfyApp { ui: { settings: settingsApi }, + refreshComboInNodes: async (): Promise => {}, registerExtension(extension: ComfyExtension): void { this.extensions.push(extension); } From b324f132d8e7c97bf5978f88035a4664047bee57 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sun, 31 May 2026 16:07:50 -0400 Subject: [PATCH 13/40] feat(detailing): add external llm segs tagging --- PLAN.md | 875 ++++++++++++------ simple_syrup/nodes/tooltips.py | 32 + simple_syrup/nodes_v3/__init__.py | 2 + .../nodes_v3/tag_segs_with_external_llm.py | 193 ++++ simple_syrup/runtime/external_llm_images.py | 103 +++ .../services/external_llm_prompt_service.py | 30 +- .../tag_segs_with_external_llm_service.py | 295 ++++++ tests/test_external_llm_prompt_service.py | 18 + tests/test_external_llm_segs_images.py | 160 ++++ tests/test_registration.py | 1 + ...test_tag_segs_with_external_llm_service.py | 360 +++++++ ...test_tag_segs_with_external_llm_v3_node.py | 145 +++ 12 files changed, 1924 insertions(+), 290 deletions(-) create mode 100644 simple_syrup/nodes_v3/tag_segs_with_external_llm.py create mode 100644 simple_syrup/services/tag_segs_with_external_llm_service.py create mode 100644 tests/test_external_llm_segs_images.py create mode 100644 tests/test_tag_segs_with_external_llm_service.py create mode 100644 tests/test_tag_segs_with_external_llm_v3_node.py diff --git a/PLAN.md b/PLAN.md index 9103302..8650296 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,310 +1,648 @@ -# Clean V3-Only Node Export Plan +# Plan: Tag SEGS w/ External LLM ## Goal -Move SimpleSyrup to a clean Comfy v3-only node export model while preserving the current SimpleSyrup node set through v3 schemas. +Add a new Comfy v3-only node named `Tag SEGS w/ External LLM`. -After this work is complete: +The node will take an image, existing Impact-compatible SEGS, and a CLIP object. It will show each SEG crop to the configured external vision LLM, turn each LLM response into a positive regional prompt, CLIP-encode those prompts in the same order as the SEGS, and return the unchanged SEGS plus an aligned `CONDITIONING_BATCH` output named `positive`. -- `comfy_entrypoint()` is the only supported ComfyUI node registration surface. -- `simple_syrup/nodes_v3/__init__.py::get_nodes()` is the authoritative node list. -- The root package no longer exposes `NODE_CLASS_MAPPINGS` or `NODE_DISPLAY_NAME_MAPPINGS`. -- Legacy Comfy mapping exports are removed instead of maintained as a migration path. -- Every SimpleSyrup node that should remain available in ComfyUI is exported as a v3 node. -- Existing workflow-facing node ids such as `SimpleSyrup.BatchRegionConditioning` remain stable where the same node continues to exist. +The intended workflow is the same regional-conditioning role currently served by `Tag SEGS w/ WD14` and `Tile & Tag SEGS`, but with an external vision model instead of WD14. The node should be useful when a vision LLM can produce better region descriptions than WD14 tags. -## Decisions Already Made +## Decisions From Planning -- This is a clean break. -- Older ComfyUI installs that only support legacy `NODE_CLASS_MAPPINGS` are not supported. -- Do not build a migration path, compatibility shim, fallback registry, dual export path, or transitional adapter for legacy Comfy exports. -- Do not keep root-level `NODE_CLASS_MAPPINGS` or `NODE_DISPLAY_NAME_MAPPINGS` for third-party import compatibility. -- Export every maintained node through Comfy v3 only. -- Keep stable node ids for maintained nodes so v3-capable Comfy can resolve the same `SimpleSyrup.*` identifiers. -- Existing `simple_syrup.nodes.*` modules may temporarily remain as implementation modules when a v3 wrapper delegates to them, but they must not own registration. -- Long-term architecture should move behavior out of legacy node classes and into services/domain/runtime modules, with v3 wrappers acting as the only Comfy-facing node API layer. -- Prompt Control-dependent nodes remain conditional, but the conditional export decision lives only in `simple_syrup/nodes_v3/__init__.py`. +- Export only through the Comfy v3 node path. +- Do not add a legacy node class. +- Do not add `NODE_CLASS_MAPPINGS` or `NODE_DISPLAY_NAME_MAPPINGS`. +- Do not add an internal compatibility shim or dual implementation path. +- Make exactly one external LLM call per SEG. +- Preserve SEGS order exactly. +- Return the original SEGS unchanged, converted only to the existing Impact-compatible output shape. +- Output one `CONDITIONING_BATCH` entry per SEG. +- Fail closed when alignment is uncertain. +- Provide a single widget for how SEG crops are presented to the vision model. +- Support LLM response cleanup controls similar to the WD14 tagging nodes. +- Treat the LLM response as prompt text after lightweight tag formatting. Do not add clever semantic parsing in the first implementation. +- Do not provide opinionated default prompt text for `system_prompt` or `user_prompt`; leave both defaults empty. ## Implementation Status -- [x] Phase 1: Characterize current export intent. - - Landing note: compared the legacy mapping registry against `nodes_v3.get_nodes()` and used that inventory to drive the wrapper list below. -- [x] Phase 2: Add missing v3 wrappers. - - Landing note: added explicit v3 wrapper classes for legacy-only nodes in `simple_syrup/nodes_v3/legacy_node_wrappers.py`; they delegate to existing implementation classes without preserving legacy registration. -- [x] Phase 3: Make the v3 registry complete. - - Landing note: `simple_syrup/nodes_v3/__init__.py::get_nodes()` now returns every maintained base node plus Prompt Control nodes only when available. -- [x] Phase 4: Remove legacy root exports. - - Landing note: root `__init__.py` no longer imports, exposes, or exports legacy mapping dictionaries. -- [x] Phase 5: Remove legacy registry authority. - - Landing note: `simple_syrup/nodes/__init__.py` is now a package marker for implementation modules, not a ComfyUI registry. -- [x] Phase 6: Update tests. - - Landing note: registration and tooltip tests now assert the v3-only contract; added adapter coverage for schema conversion and hidden-input delegation. -- [x] Phase 7: Update documentation and guardrails. - - Landing note: `AGENTS.md` now identifies Comfy v3 as the only supported export path and removes dual-export requirements. -- [x] Final verification gates. - - Landing note: `ruff format .`, `ruff check .`, `mypy --strict simple_syrup tests`, and full `pytest -n auto -q` all pass. The remaining production `NODE_CLASS_MAPPINGS` text is a runtime read of ComfyUI's global registry in latent provenance tracing, not a SimpleSyrup registration/export path. - -## Non-Goals - -- Do not support old ComfyUI installations that do not call `comfy_entrypoint()`. -- Do not preserve imports of `SimpleSyrup.NODE_CLASS_MAPPINGS`. -- Do not preserve imports of `SimpleSyrup.NODE_DISPLAY_NAME_MAPPINGS`. -- Do not keep `simple_syrup/nodes/__init__.py` as a registry. -- Do not rename maintained node ids as part of this change. -- Do not use this refactor to change node behavior, defaults, output order, return shapes, categories, or display names unless a v3 schema cannot represent the current contract and the behavior change is explicitly tested. - -## Current Export State - -The repository currently has two export paths: - -- Legacy mappings in `simple_syrup/nodes/__init__.py`. -- Comfy v3 node list in `simple_syrup/nodes_v3/__init__.py`. - -The root `__init__.py` currently imports and exports legacy mappings while also defining `comfy_entrypoint()`. The final state must remove the legacy mapping import/export from the root package. - -## Existing V3 Coverage - -The following nodes already have v3 wrappers or v3 classes and must remain in the v3 registry: - -- `SimpleSyrup.BatchRegionConditioning` -- `SimpleSyrup.BatchSEGS` -- `SimpleSyrup.ExternalLLMPrompt` -- `SimpleSyrup.ScaleFactor` -- `SimpleSyrup.SimpleLoadCheckpoint` -- `SimpleSyrup.TagSEGSWithWD14` -- `SimpleSyrup.TileAndTagSEGS` -- `SimpleSyrup.VAEDecodeOptions` -- `SimpleSyrup.VAEEncodeOptions` -- `SimpleSyrup.WD14TaggerLoader` -- `SimpleSyrup.EncodePromptBatchWithPromptControl`, when Prompt Control is available -- `SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl`, when Prompt Control is available - -Verify this list against the code before implementation. If new v3 wrappers have been added since this plan was written, treat the code as authoritative and update the implementation checklist accordingly. - -## Legacy-Only Nodes To Port - -The following current legacy mapping nodes must receive v3 wrappers before legacy exports are removed: +- [x] Review plan and current code patterns. + - Landing note: existing `Tag SEGS w/ WD14` owns SEGS validation/alignment, `External LLM Prompt` owns provider settings/key flow, and v3 registration is centralized in `simple_syrup/nodes_v3/__init__.py`. +- [x] Add SEG crop image encoder and tests. + - Landing note: `ExternalLLMSegsImageEncoder` now emits PNG data URLs for `transparent mask`, `black mask`, and `full crop`; focused encoder tests pass. +- [x] Add external LLM image-data-url execution path. + - Landing note: `ExternalLLMPromptService.generate_with_image_data_url()` reuses the existing model/settings/key/client flow while accepting the SEG encoder's prebuilt PNG data URL. +- [x] Add service formatting and orchestration tests. + - Landing note: service tests cover per-SEG LLM call order, prompt formatting, exclusions, progress, logging metadata, validation failures, and conditioning count alignment. +- [x] Add `TagSEGSWithExternalLLMService`. + - Landing note: the service owns SEGS validation, SEG image encoding, pre-encoded LLM calls, response formatting, prompt prefixing, CLIP conditioning encoding, and completion logging. +- [x] Add v3-only node schema and execution forwarding. + - Landing note: `TagSEGSWithExternalLLMV3` defines the schema directly and delegates to `TagSEGSWithExternalLLMService`; no file was added under `simple_syrup/nodes/`. `system_prompt` and `user_prompt` default to empty strings. +- [x] Register v3 node and update registration tests. + - Landing note: `SimpleSyrup.TagSEGSWithExternalLLM` is now returned by `get_nodes()` with the maintained base node list. +- [x] Add tooltips and update tooltip coverage. + - Landing note: all new v3 inputs/outputs use dedicated tooltip constants, and focused registration/tooltip tests pass. +- [x] Update this plan with final landing notes. + - Landing note: implementation completed as a v3-only vertical slice with no new `simple_syrup/nodes/` node class and no legacy mapping export changes. +- [x] Run required verification gates. + - Landing note: `ruff format .`, `ruff check .`, `mypy --strict simple_syrup tests`, and `pytest -n auto -q` pass in `..\..\venv`. -- `SimpleSyrup.ConditioningBatchStart` -- `SimpleSyrup.ConditioningBatchAppend` -- `SimpleSyrup.GroundedSAMModelInfo` -- `SimpleSyrup.GroundingDINOModelLoader` -- `SimpleSyrup.KSamplerExtras` -- `SimpleSyrup.KSamplerTiledDiffusion` -- `SimpleSyrup.LayerStyleSAMModelsAdapter` -- `SimpleSyrup.LatentDiagnostics` -- `SimpleSyrup.PromptEncodeStyle` -- `SimpleSyrup.PromptEncodeStyleAndNormalization` -- `SimpleSyrup.PromptSEGSWithSAM` -- `SimpleSyrup.SimpleVAEEncode` -- `SimpleSyrup.UpscaleLatentFromImage` -- `SimpleSyrup.ResizeImageToTarget` -- `SimpleSyrup.DetailSEGSAsRegions` -- `SimpleSyrup.DetailSEGSByScaleFactor` -- `SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion` -- `SimpleSyrup.SAMModelLoader` -- `SimpleSyrup.Seed` -- `SimpleSyrup.SimpleLoadAnima` -- `SimpleSyrup.LoadUltralyticsModel` -- `SimpleSyrup.DetectSEGSWithUltralytics` -- `SimpleSyrup.EncodePromptBatch` -- `SimpleSyrup.ViTMatteModelLoader` +## New Node Contract -Port these as maintained v3 nodes. Do not remove a node from the product by omission. +Node id: -## Final Architecture +```text +SimpleSyrup.TagSEGSWithExternalLLM +``` -### Root Package +Display name: -File: `__init__.py` +```text +Tag SEGS w/ External LLM +``` -The root package should: +Category: -- Set up the local `simple_syrup` package alias if it is still required. -- Define `WEB_DIRECTORY`. -- Define `comfy_entrypoint()`. -- Register backend routes that are required at import time. -- Export `WEB_DIRECTORY` and `comfy_entrypoint` in `__all__`. +```text +SimpleSyrup/Detailing +``` -The root package should not: +Inputs: -- Import `simple_syrup.nodes.NODE_CLASS_MAPPINGS`. -- Import `simple_syrup.nodes.NODE_DISPLAY_NAME_MAPPINGS`. -- Define `NODE_CLASS_MAPPINGS`. -- Define `NODE_DISPLAY_NAME_MAPPINGS`. -- Include either mapping name in `__all__`. +```text +image: IMAGE +segs: SEGS +clip: CLIP +model: external LLM model dropdown +system_prompt: STRING +user_prompt: STRING +universal_positive: STRING +seg_image_mode: COMBO +replace_underscore: BOOLEAN +trailing_comma: BOOLEAN +exclude_tags: STRING +max_tokens: INT +reasoning_effort: COMBO +``` -### V3 Registry +Outputs: -File: `simple_syrup/nodes_v3/__init__.py` +```text +segs: SEGS +positive: CONDITIONING_BATCH +``` -`get_nodes()` should: +Suggested default input values: -- Import v3 node classes lazily inside the function. -- Return every maintained SimpleSyrup v3 node class. -- Keep Prompt Control-only imports inside the availability branch. -- Keep unrelated nodes exported when Prompt Control is unavailable. -- Be the only registry used by ComfyUI. +```text +system_prompt: -The module should not: +user_prompt: -- Import or depend on legacy mapping dictionaries. -- Mirror any legacy registration structure. -- Use compatibility fallback behavior for legacy ComfyUI. +universal_positive: -### V3 Wrappers +seg_image_mode: +transparent mask -Directory: `simple_syrup/nodes_v3/` +replace_underscore: +true -Each v3 wrapper should: +trailing_comma: +false -- Use the same `node_id` as the maintained legacy node id. -- Use the same display name. -- Use the same category. -- Preserve input names, default values, accepted types, optional inputs, hidden inputs, and widget behavior where v3 supports them. -- Preserve output ids, output types, and return shapes. -- Include concise user-facing descriptions and tooltips. -- Delegate non-trivial behavior to existing services/domain/runtime code. -- Avoid duplicating business logic that already has an authoritative service. -- Narrow `Any` at Comfy boundary points before core logic relies on values. -- Use docstrings for new or changed modules, classes, functions, and methods. +exclude_tags: -Temporary delegation to a legacy node implementation class is acceptable only as an implementation detail during this clean export break. It must not preserve or reintroduce legacy registration. +max_tokens: +1024 -### Legacy Node Package +reasoning_effort: +default +``` -File: `simple_syrup/nodes/__init__.py` +Search aliases: -This file must stop being a registry. +```text +llm +vision +tag +segs +detail +regional +prompt +``` -Acceptable end states: +## SEG Image Modes -- Delete the legacy registry file if no internal imports require `simple_syrup.nodes` package exports. -- Reduce it to a normal package marker with a short module docstring and no node mapping dictionaries. -- Keep selective non-registry imports only if they are required by internal code and do not create an alternate registration surface. +Add one combo input named `seg_image_mode` with these exact options: -Unacceptable end states: +```text +transparent mask +black mask +full crop +``` -- `NODE_CLASS_MAPPINGS` still exists. -- `NODE_DISPLAY_NAME_MAPPINGS` still exists. -- `__all__` advertises legacy node mappings. -- The file remains the source of truth for exported nodes. +### `transparent mask` -## Implementation Phases +Crop the original image to `segment.crop_region`. -### Phase 1: Characterize Current Export Intent +Crop the SEG mask to the same rectangle. Resize or normalize the mask only if needed to match the crop dimensions. -1. Read `simple_syrup/nodes/__init__.py`. -2. Record every legacy node id, class, and display name. -3. Read `simple_syrup/nodes_v3/__init__.py`. -4. Record every existing v3 node id. -5. Build a one-to-one checklist showing which maintained nodes already have v3 wrappers and which need wrappers. -6. Add or update tests that prove the current intended node set before removing legacy exports. +Encode a PNG data URL with RGBA channels: -The checklist in this plan is the starting point, but implementation must verify against the current files because the working tree may have changed. +- RGB comes from the crop. +- Alpha comes from the cropped SEG mask. +- Pixels outside the mask are hidden by alpha. -### Phase 2: Add Missing V3 Wrappers +Use this as the default because it most directly communicates "only this SEG matters" when the provider supports alpha. -For each legacy-only maintained node: +### `black mask` -1. Create a v3 wrapper module under `simple_syrup/nodes_v3/`. -2. Implement `define_schema()` using `comfy_api.latest.io`. -3. Implement `execute()` or the correct v3 execution hook for the node. -4. Preserve the node id, display name, category, inputs, outputs, defaults, and return shape. -5. Delegate behavior to the existing service/domain/runtime owner where possible. -6. Add focused tests for the wrapper schema and execution behavior. -7. Add tooltip coverage for visible inputs, optional inputs, hidden inputs when supported, and outputs. +Crop the original image to `segment.crop_region`. -Suggested grouping: +Crop the SEG mask to the same rectangle. Resize or normalize the mask only if needed to match the crop dimensions. -- Simple value/helper nodes: `Seed`, `ScaleFactor` parity checks, conditioning batch nodes. -- Model loader and adapter nodes: SAM, GroundingDINO, ViTMatte, Ultralytics, LayerStyle, Anima. -- Image/latent utility nodes: resize, latent diagnostics, VAE provenance nodes. -- Prompt nodes: encode style, style normalization, prompt batch. -- SEGS nodes: prompt SEGS, detect SEGS, detail SEGS variants. -- Sampler nodes: `KSamplerExtras`, `KSamplerTiledDiffusion`. +Encode an RGB PNG data URL: -Sampler and SEGS nodes are higher risk because they touch workflow-facing behavior, hidden inputs, lazy expansion, or runtime side effects. Add characterization tests before changing those wrappers. +- Pixels inside the mask keep the original crop color. +- Pixels outside the mask are set to black. -### Phase 3: Make The V3 Registry Complete +This is the compatibility mode for providers that ignore PNG alpha or flatten transparent pixels unpredictably. -1. Update `simple_syrup/nodes_v3/__init__.py`. -2. Import every maintained v3 node lazily inside `get_nodes()`. -3. Return all normal nodes in a deterministic order. -4. Keep Prompt Control-only nodes conditional. -5. Add tests for Prompt Control available and unavailable states. -6. Assert that unavailable Prompt Control removes only Prompt Control-specific nodes and does not remove unrelated v3 nodes. +### `full crop` -The deterministic order does not need to match the old legacy dictionary order exactly, but it should be stable and easy to review. +Crop the original image to `segment.crop_region`. -### Phase 4: Remove Legacy Root Exports +Encode the full RGB crop unchanged. -1. Edit root `__init__.py`. -2. Remove imports of `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS`. -3. Remove those names from `__all__`. -4. Keep `WEB_DIRECTORY`. -5. Keep route registration. -6. Keep `comfy_entrypoint()`. -7. Add tests that assert the root package has no legacy mapping attributes. -8. Add tests that assert `comfy_entrypoint().get_node_list()` returns every maintained node id. +Ignore the SEG mask for the image sent to the LLM. This gives the model surrounding context when context improves region tagging. -This is the main clean-break step. +## Formatting Rules For LLM Responses -### Phase 5: Remove Legacy Registry Authority +Add formatting controls: -1. Edit `simple_syrup/nodes/__init__.py`. -2. Remove `NODE_CLASS_MAPPINGS`. -3. Remove `NODE_DISPLAY_NAME_MAPPINGS`. -4. Remove registry-only imports. -5. Remove registry-only `__all__` entries. -6. Keep the package importable if internal modules still import from `simple_syrup.nodes`. +```text +replace_underscore: BOOLEAN = true +trailing_comma: BOOLEAN = false +exclude_tags: STRING = "" +``` -Do not delete individual legacy node implementation modules in this phase unless their behavior has been fully moved into services/domain/runtime and all callsites have been updated. +For each LLM response: -### Phase 6: Update Tests +1. Strip leading and trailing whitespace. +2. Reject the response if it is empty after stripping. +3. Split the response by commas into tag-like chunks. +4. Trim whitespace around each chunk. +5. Drop empty chunks. +6. If `replace_underscore` is enabled, replace `_` with spaces inside each chunk. +7. Parse `exclude_tags` as a comma-separated list. +8. Normalize excluded tags using the same trim, empty-drop, and optional underscore replacement policy. +9. Remove exact tag matches after normalization. +10. Rejoin remaining tags with `, `. +11. Reject the prompt if no tags remain after exclusion. +12. If `trailing_comma` is enabled and the prompt does not already end in a comma, append `,`. +13. Prefix with `universal_positive` using `domain.prompt_composition.prefix_prompt`. -Update the test suite to match the new contract. +Do not fuzzy-match excluded tags. -Remove or rewrite tests that assert: +Do not parse JSON, markdown, prose sections, or model-specific response schemas in the first implementation. Prompt discipline should keep responses comma-like. If a model returns prose, the comma splitting still gives deterministic behavior. -- Root package exposes `NODE_CLASS_MAPPINGS`. -- Root package exposes `NODE_DISPLAY_NAME_MAPPINGS`. -- Node ids are registered through `simple_syrup.nodes.NODE_CLASS_MAPPINGS`. -- Display names are registered through `simple_syrup.nodes.NODE_DISPLAY_NAME_MAPPINGS`. +## Architecture -Add or update tests that assert: +Follow the existing layer boundaries in `AGENTS.md`. -- Root package exposes `comfy_entrypoint`. -- Root package exposes `WEB_DIRECTORY`. -- Root package does not expose `NODE_CLASS_MAPPINGS`. -- Root package does not expose `NODE_DISPLAY_NAME_MAPPINGS`. -- `comfy_entrypoint().get_node_list()` includes every maintained node id. -- Every v3 schema has a non-empty `description`. -- Every v3 visible input has a tooltip where the Comfy v3 API supports it. -- Every v3 output has a tooltip where the Comfy v3 API supports it. -- Prompt Control conditional nodes appear only when Prompt Control is available. -- Prompt Control unavailable state does not remove unrelated nodes. -- V3 wrapper execution preserves legacy behavior for each ported node. +### Domain Layer -Prefer schema tests for Comfy-facing contracts and service/domain tests for behavior. +Use existing domain objects where possible: -### Phase 7: Update Documentation And Guardrails +- `simple_syrup/domain/segs.py` +- `simple_syrup/domain/conditioning_batch.py` +- `simple_syrup/domain/prompt_composition.py` +- `simple_syrup/domain/external_llm.py` -1. Update `AGENTS.md` to say v3 is the only export path. -2. Remove language requiring updates to legacy mapping exports. -3. Remove language requiring the root package to expose legacy mappings. -4. Update node registration testing rules to reference v3 schemas and `comfy_entrypoint()`. -5. Update Definition of Done language so new, renamed, or removed nodes are verified through v3 only. -6. Search docs for stale statements that claim legacy and v3 exports are both required. -7. Update only documentation that directly describes current engineering rules or current user-facing behavior. +Add a small domain value object only if it removes duplication or clarifies validation. A likely useful object is a frozen dataclass for formatting controls: -Do not create additional design docs unless explicitly requested. +```python +@dataclass(frozen=True) +class LLMTagFormattingControls: + replace_underscore: bool + trailing_comma: bool + exclude_tags: str +``` -## Testing Requirements +Place it where ownership is clearest after implementation. If the formatting is used only by this service, keep it service-local. If it becomes shared with future LLM tagging code, place it in a domain module. -Run focused tests while implementing, then run the full required gates before completion. +### Runtime / Adapter Layer -Required commands from repository root: +Add a crop image encoder near: + +```text +simple_syrup/runtime/external_llm_images.py +``` + +Recommended additions: + +```python +SEG_IMAGE_MODES = ("transparent mask", "black mask", "full crop") + +class ExternalLLMSegsImageEncoder: + """Encode SEG crops for OpenAI-compatible vision payloads.""" + + def encode_segment_as_data_url( + self, + image: torch.Tensor, + segment: Segment, + mode: str, + ) -> str: + """Return one SEG crop as a PNG data URL.""" +``` + +This adapter owns tensor-to-PIL conversion and PNG/base64 data URL encoding. + +The existing `ExternalLLMImageEncoder.encode_first_image_as_data_url()` sends the first whole image. Do not overload that method with SEG-specific behavior. Keep the SEG crop behavior separate so the existing `External LLM Prompt` node remains simple and unchanged. + +Use existing masking helpers where possible: + +- `validate_single_image` +- `crop_image` +- `crop_mask` +- `resize_mask` + +If `segment.cropped_mask` is already crop-local, do not assume it is full-image. The service or encoder must normalize the mask robustly: + +- If the mask shape matches the crop height and width, use it directly. +- If the mask shape matches the full source image height and width, crop it by `segment.crop_region`. +- If the mask has a batch dimension, normalize to one HW mask. +- If the mask shape is compatible but not exact, resize with bilinear interpolation and clamp to `0.0..1.0`. +- If the mask cannot be interpreted as HW or BHW, raise a clear `ValueError`. + +Preserve security boundaries: + +- No filesystem writes are needed. +- No workflow-provided code execution. +- No shell invocation. +- No network logic inside the image encoder. + +### Application / Service Layer + +Add a service module: + +```text +simple_syrup/services/tag_segs_with_external_llm_service.py +``` + +Recommended public result: + +```python +@dataclass(frozen=True) +class TagSEGSWithExternalLLMResult: + """Return unchanged SEGS and aligned external-LLM conditioning.""" + + segs: ImpactSegs + positive: ConditioningBatch +``` + +Recommended service: + +```python +class TagSEGSWithExternalLLMService: + """Caption provided SEGS crops with an external LLM and encode conditioning.""" +``` + +The service should own: + +- Input validation. +- External LLM model resolution. +- External LLM settings and credential checks. +- One provider request per SEG. +- Progress reporting. +- Response formatting. +- Prompt prefixing. +- CLIP conditioning encoding. +- Alignment checks. +- Structured logging. + +Keep external boundaries injectable for tests: + +```python +class VisionLLMBoundary(Protocol): + def generate( + self, + model: str, + system_prompt: str, + user_prompt: str, + max_tokens: int, + reasoning_effort: str, + image: object | None = None, + ) -> str: + """Return one assistant response.""" + + +class SegmentImageEncodingBoundary(Protocol): + def encode_segment_as_data_url( + self, + image: torch.Tensor, + segment: Segment, + mode: str, + ) -> str: + """Return one SEG crop image data URL.""" + + +class ConditioningEncodingBoundary(Protocol): + def encode_batch(self, clip: Any, chunks: tuple[str, ...]) -> ConditioningBatch: + """Return conditioning entries in prompt order.""" +``` + +The existing `ExternalLLMPromptService.generate()` expects an `image` object and then asks its own image encoder to encode it. For this node, prefer adding a provider-call method that accepts a prebuilt image data URL, or extract shared provider execution into a helper. Do not pass already encoded data URLs through an API that claims to accept Comfy `IMAGE` tensors. + +Recommended clean shape: + +- Keep `ExternalLLMPromptService.generate()` behavior unchanged. +- Add a method to the same service or a new small collaborator that accepts `image_data_url`. +- Reuse the same settings repository, key store, model choice, and provider client logic. +- Avoid duplicating endpoint/API-key validation across services if it can be extracted without creating an unclear abstraction. + +The service execution should look like: + +1. Validate `image` as a single BHWC IMAGE tensor. +2. Coerce `segs` with `coerce_segs`. +3. Validate SEGS header matches the image height and width. +4. Validate every `segment.crop_region` fits inside the image. +5. Reject empty SEGS. +6. Resolve the selected LLM model. +7. Create progress with `len(segments) + 2`. +8. For each segment in input order: + - Encode one SEG crop according to `seg_image_mode`. + - Call the external LLM once with that crop. + - Format the LLM response. + - Prefix with `universal_positive`. + - Append the prompt to an ordered tuple/list. + - Update progress. +9. Encode the prompt tuple using `ComfyConditioningEncoder.encode_batch`. +10. Verify `len(positive.entries) == len(segments)`. +11. Log completion with operation name, segment count, selected model, image mode, and formatting-control presence. +12. Return `to_impact_compatible_segs(native_segs)` and `positive`. + +Suggested operation constant: + +```python +OPERATION = "Tag SEGS w/ External LLM" +``` + +### Comfy v3 Node Layer + +Add only: + +```text +simple_syrup/nodes_v3/tag_segs_with_external_llm.py +``` + +Do not add: + +```text +simple_syrup/nodes/tag_segs_with_external_llm.py +``` + +The v3 node should: + +- Define the schema directly. +- Use `comfy_api.latest`. +- Use `Custom("CONDITIONING_BATCH")` for the positive output. +- Use model choices from the external LLM service, like the current `External LLM Prompt` node does. +- Delegate non-trivial behavior to `TagSEGSWithExternalLLMService`. +- Contain only input declaration and execution forwarding. + +Register the node in: + +```text +simple_syrup/nodes_v3/__init__.py +``` + +The root package export must continue to expose only `comfy_entrypoint`. + +## Tooltips + +Update: + +```text +simple_syrup/nodes/tooltips.py +``` + +Every visible input and output needs a concise user-facing tooltip. + +Suggested tooltip intent: + +- `image`: The source image that the SEGS were detected from. +- `segs`: The regions to describe and align with conditioning. +- `clip`: The CLIP/text encoder used to encode generated regional prompts. +- `model`: The configured external vision model used for each SEG crop. +- `system_prompt`: Instructions that control how the model writes tags. +- `user_prompt`: The per-region request sent with each SEG crop. +- `universal_positive`: Prompt text added before every generated region prompt. +- `seg_image_mode`: How pixels outside the SEG mask are shown to the vision model. +- `replace_underscore`: Converts booru-style underscores into spaces before encoding. +- `trailing_comma`: Adds a final comma to each generated prompt when enabled. +- `exclude_tags`: Comma-separated exact tags to remove from LLM responses. +- `max_tokens`: Maximum response length for each SEG request. +- `reasoning_effort`: Provider-specific reasoning control, when supported. +- `segs` output: The original SEGS, kept in the same order as the conditioning batch. +- `positive` output: One CLIP-encoded positive conditioning entry per SEG. + +Keep wording concise. Do not document removed choices or alternatives the node does not expose. + +## Tests + +Add or update tests alongside implementation. + +### Image Encoder Tests + +Add: + +```text +tests/test_external_llm_segs_images.py +``` + +Cover: + +- `transparent mask` returns a PNG data URL with alpha. +- Alpha is low/zero outside the SEG mask and high/one inside it. +- `black mask` returns RGB where outside-mask pixels are black. +- `full crop` preserves the crop rectangle without masking. +- Crop dimensions match `segment.crop_region`. +- Crop-local masks are accepted. +- Full-image masks are accepted and cropped. +- Invalid mask rank fails with an actionable `ValueError`. +- Unknown `seg_image_mode` fails with an actionable `ValueError`. + +Use small deterministic tensors, such as 4x4 or 6x6 images, so pixel assertions are simple. + +### Service Tests + +Add: + +```text +tests/test_tag_segs_with_external_llm_service.py +``` + +Cover: + +- Existing SEGS, LLM responses, formatted prompts, and conditioning stay aligned by index. +- The service makes one LLM call per SEG. +- The service sends the selected `seg_image_mode` to the encoder. +- `universal_positive` is prefixed to every generated prompt. +- `replace_underscore=True` turns `blue_hair` into `blue hair`. +- `replace_underscore=False` preserves `blue_hair`. +- `exclude_tags` removes exact normalized tags. +- `trailing_comma=True` adds a comma after formatting. +- Empty SEGS raise `ValueError`. +- Mismatched SEGS header and image dimensions raise `ValueError`. +- Crop regions outside the image raise `ValueError`. +- Empty LLM responses raise `ValueError`. +- Responses that become empty after exclusions raise `ValueError`. +- Conditioning encoder returning the wrong entry count raises `ValueError`. +- Completion logging includes operation, segment count, model, image mode, and universal-positive presence. + +Use fake boundaries for LLM calls, image encoding, conditioning encoding, and progress. Do not call a real provider in tests. + +### V3 Node Tests + +Add: + +```text +tests/test_tag_segs_with_external_llm_v3_node.py +``` + +Cover: + +- Schema node id is `SimpleSyrup.TagSEGSWithExternalLLM`. +- Display name is `Tag SEGS w/ External LLM`. +- Category is `SimpleSyrup/Detailing`. +- Inputs are exposed in the intended order. +- `seg_image_mode` choices are exactly `transparent mask`, `black mask`, `full crop`. +- `transparent mask` is the default. +- Formatting controls have intended defaults. +- Outputs are `segs` and `positive`. +- Output types are `SEGS` and `CONDITIONING_BATCH`. +- `execute()` forwards all inputs to the service. + +### Registration Tests + +Update existing registration/v3 export tests. The new node must appear in the v3 entrypoint output from: + +```text +simple_syrup/nodes_v3/__init__.py::get_nodes() +``` + +Also update tooltip coverage tests so the new node does not create coverage gaps. + +## Validation And Errors + +Use explicit, actionable errors. + +Required validation failures: + +- `image` is not a torch IMAGE tensor. +- `image` batch size is not 1. +- `segs` is not a valid SEGS payload. +- SEGS header dimensions do not match the image. +- SEGS contains no segments. +- A SEG crop region is outside the image. +- `seg_image_mode` is unknown. +- The external LLM provider is not configured. +- The external LLM API key is missing. +- The external LLM response is empty. +- Formatting removes every generated tag. +- The conditioning encoder returns a count different from the SEG count. + +Do not silently continue after invalid inputs or provider failures. + +## Logging And Progress + +Use `simple_syrup.shared.logging.get_logger`. + +At completion, log an `info` event with: + +```text +operation: tag_segs_with_external_llm +segment_count +external_llm_model +seg_image_mode +universal_positive_present +replace_underscore +trailing_comma +exclude_tags_present +``` + +Provider failures should preserve exception context. Existing external LLM provider errors can propagate if they are already actionable. + +Use Comfy progress in the service: + +- Start with one initial progress update after validation/model resolution. +- Update after each SEG LLM call. +- Update once after conditioning encode. + +## Security And Runtime Constraints + +- Use the ComfyUI virtual environment located at `..\..\venv`. +- Do not create a repository-local `.venv`. +- Do not use global Python for verification. +- Use PowerShell command forms on Windows. +- Do not write temporary crop files to disk. +- Do not log API keys, provider secrets, or full data URLs. +- Do not execute workflow-provided code. +- Keep network calls inside the external LLM client/provider boundary. +- Keep filesystem, subprocess, and network behavior out of node classes and domain logic. +- Keep import-time behavior cheap. The node schema may read cached model names, but must not make provider requests during import/schema declaration. + +## Required Files To Touch + +Expected new files: + +```text +simple_syrup/services/tag_segs_with_external_llm_service.py +simple_syrup/nodes_v3/tag_segs_with_external_llm.py +tests/test_external_llm_segs_images.py +tests/test_tag_segs_with_external_llm_service.py +tests/test_tag_segs_with_external_llm_v3_node.py +``` + +Expected modified files: + +```text +simple_syrup/runtime/external_llm_images.py +simple_syrup/services/external_llm_prompt_service.py +simple_syrup/nodes/tooltips.py +simple_syrup/nodes_v3/__init__.py +tests/test_registration.py +tests/test_node_tooltips.py +``` + +Only modify additional files if implementation proves they are the correct ownership location. + +Do not add a file under: + +```text +simple_syrup/nodes/ +``` + +for this node. + +## Implementation Sequence + +1. Add characterization tests around the existing external LLM prompt service if extraction is needed. +2. Add the SEG crop image encoder and image-mode tests. +3. Add response formatting logic and service tests. +4. Add the application service with fake-boundary tests. +5. Add the v3 node schema and execute forwarding tests. +6. Register the v3 node and update registration tests. +7. Add tooltips and update tooltip coverage tests. +8. Run focused tests while developing. +9. Run full verification gates before completion. + +## Verification Commands + +Run all commands from the repository root with PowerShell syntax. + +Required gates: ```powershell ..\..\venv\Scripts\ruff.exe format . @@ -313,53 +651,20 @@ Required commands from repository root: ..\..\venv\Scripts\python.exe -m pytest -n auto -q ``` -If frontend files are touched, also run: +Do not substitute global tools. -```powershell -npm ci -npm run lint:web -npm run typecheck:web -npm run test:web -npm run build:web -``` +If a required tool is missing from `..\..\venv`, install or update development dependencies in that environment before verification. Do not create a local `.venv`. -Do not use global Python, global pytest, global ruff, global mypy, or a repository-local `.venv`. +## Definition Of Done -## Acceptance Criteria - -The work is complete when all of these are true: - -- `SimpleSyrup.NODE_CLASS_MAPPINGS` is gone. -- `SimpleSyrup.NODE_DISPLAY_NAME_MAPPINGS` is gone. -- `simple_syrup.nodes.NODE_CLASS_MAPPINGS` is gone. -- `simple_syrup.nodes.NODE_DISPLAY_NAME_MAPPINGS` is gone. -- `comfy_entrypoint()` is present and returns a Comfy extension. -- `comfy_entrypoint().get_node_list()` returns every maintained non-conditional node. -- Prompt Control conditional nodes are exported only when Prompt Control is available. -- Every maintained current node has a v3 schema. -- V3 node ids match maintained current `SimpleSyrup.*` ids. -- Node display names, categories, inputs, defaults, outputs, and execution behavior are preserved unless explicitly changed and tested. -- Tests no longer depend on legacy mapping exports. -- `AGENTS.md` describes v3-only exports. -- Required Python gates pass. -- Required frontend gates pass if frontend files are touched. - -## Review Checklist - -Before requesting review, verify: - -- Search for `NODE_CLASS_MAPPINGS` returns no production registration path. -- Search for `NODE_DISPLAY_NAME_MAPPINGS` returns no production registration path. -- Search for `simple_syrup.nodes_v3` confirms `get_nodes()` is the sole node registry. -- Root `__all__` includes only supported public exports. -- No compatibility layer was added for old ComfyUI. -- No maintained node disappeared from the v3 node list. -- No Prompt Control import happens when Prompt Control is unavailable. -- Error messages exposed to Comfy users remain actionable. -- Tooltips remain concise and user-facing. - -## Notes For New Contributors - -Comfy v3 nodes are schema-driven. Start by opening an existing wrapper in `simple_syrup/nodes_v3/`, then mirror that pattern for the node you are porting. The wrapper should describe the Comfy-facing contract and delegate real work to existing code. - -When in doubt about behavior, write a characterization test around the current legacy node before creating the v3 wrapper. The clean break removes the old export path, not the maintained node behavior. +- The node is exported only through Comfy v3. +- `SimpleSyrup.TagSEGSWithExternalLLM` appears in `get_nodes()`. +- The node returns unchanged SEGS plus one positive conditioning entry per SEG. +- One external LLM call is made per SEG. +- `transparent mask`, `black mask`, and `full crop` are implemented and tested. +- Formatting controls are implemented and tested. +- All validation failures are explicit and actionable. +- Tooltips cover every input and output. +- Tests cover service behavior, image encoding, v3 schema, execution forwarding, registration, and tooltip coverage. +- No legacy node class or legacy mapping export is introduced. +- Required ruff, mypy, and pytest gates pass in `..\..\venv`. diff --git a/simple_syrup/nodes/tooltips.py b/simple_syrup/nodes/tooltips.py index f9505a5..3d970f3 100644 --- a/simple_syrup/nodes/tooltips.py +++ b/simple_syrup/nodes/tooltips.py @@ -78,6 +78,38 @@ EXTERNAL_LLM_IMAGE_INPUT = ( ) EXTERNAL_LLM_RESPONSE_OUTPUT = "Assistant response returned by the external model." +EXTERNAL_LLM_TAG_SEGS_IMAGE = "Source image that the incoming SEGS were detected from." +EXTERNAL_LLM_TAG_SEGS_SEGS = ( + "Existing SEGS to describe and keep aligned with the conditioning batch." +) +EXTERNAL_LLM_TAG_SEGS_CLIP = "CLIP model used to encode each generated regional prompt." +EXTERNAL_LLM_TAG_SEGS_MODEL = "External vision model used to describe each SEG crop." +EXTERNAL_LLM_TAG_SEGS_SYSTEM_PROMPT = ( + "Instruction text that controls how the model writes regional tags." +) +EXTERNAL_LLM_TAG_SEGS_USER_PROMPT = "Per-region request sent with each SEG crop image." +EXTERNAL_LLM_TAG_SEGS_UNIVERSAL_POSITIVE = ( + "Positive prompt text added before every generated regional prompt." +) +EXTERNAL_LLM_TAG_SEGS_IMAGE_MODE = ( + "How pixels outside each SEG mask are shown to the vision model." +) +EXTERNAL_LLM_TAG_SEGS_REPLACE_UNDERSCORE = ( + "Replace underscores in generated tags before CLIP encoding." +) +EXTERNAL_LLM_TAG_SEGS_TRAILING_COMMA = ( + "Add a final comma to each generated regional prompt." +) +EXTERNAL_LLM_TAG_SEGS_EXCLUDE_TAGS = ( + "Comma-separated exact tags removed from generated regional prompts." +) +EXTERNAL_LLM_TAG_SEGS_SEGS_OUTPUT = ( + "Original SEGS returned in the same order as the conditioning batch." +) +EXTERNAL_LLM_TAG_SEGS_POSITIVE_OUTPUT = ( + "Positive conditioning from external LLM tags, matched to SEGS order." +) + SAMPLING_MODEL = "Diffusion model used to denoise the input latent." SAMPLING_SEED = ( "Seed used to create sampling noise. Reusing it with matching settings makes " diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index 9983c25..10b1de1 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -43,6 +43,7 @@ def get_nodes() -> list[type[object]]: ) from .scale_factor import ScaleFactorV3 from .simple_load_checkpoint import SimpleLoadCheckpointV3 + from .tag_segs_with_external_llm import TagSEGSWithExternalLLMV3 from .tag_segs_with_wd14 import TagSEGSWithWD14V3 from .tile_and_tag_segs import TileAndTagSEGSV3 from .vae_decode_options import VAEDecodeOptionsV3 @@ -77,6 +78,7 @@ def get_nodes() -> list[type[object]]: SimpleLoadAnimaV3, SimpleLoadCheckpointV3, SimpleVAEEncodeV3, + TagSEGSWithExternalLLMV3, TagSEGSWithWD14V3, TileAndTagSEGSV3, UpscaleLatentFromImageV3, diff --git a/simple_syrup/nodes_v3/tag_segs_with_external_llm.py b/simple_syrup/nodes_v3/tag_segs_with_external_llm.py new file mode 100644 index 0000000..0efc6c8 --- /dev/null +++ b/simple_syrup/nodes_v3/tag_segs_with_external_llm.py @@ -0,0 +1,193 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node for tagging existing SEGS with an external LLM.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..domain.external_llm import ( + DEFAULT_EXTERNAL_LLM_MAX_TOKENS, + DEFAULT_EXTERNAL_LLM_REASONING_EFFORT, + EXTERNAL_LLM_REASONING_EFFORTS, +) +from ..nodes import tooltips +from ..runtime.external_llm_images import SEG_IMAGE_MODES +from ..services.tag_segs_with_external_llm_service import ( + LLMTagFormattingControls, + TagSEGSWithExternalLLMService, +) + +MAX_EXTERNAL_LLM_MAX_TOKENS = 32768 +DEFAULT_SEG_IMAGE_MODE = "transparent mask" + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io +ConditioningBatchIO: Any = ( + None if TYPE_CHECKING else _comfy_io.Custom("CONDITIONING_BATCH") +) + + +class TagSEGSWithExternalLLMV3(_ComfyNodeBase): + """Expose external-LLM SEGS tagging through Comfy's v3 API.""" + + _service = TagSEGSWithExternalLLMService() + + @classmethod + def define_schema(cls) -> Any: + """Declare the Tag SEGS w/ External LLM v3 schema.""" + + model_choices = cls._service.model_choices() + return _comfy_io.Schema( + node_id="SimpleSyrup.TagSEGSWithExternalLLM", + display_name="Tag SEGS w/ External LLM", + category="SimpleSyrup/Detailing", + description=( + "Tags existing SEGS crops with a configured external vision LLM " + "and returns aligned conditioning for SEGS detailing." + ), + search_aliases=[ + "llm", + "vision", + "tag", + "segs", + "detail", + "regional", + "prompt", + ], + inputs=[ + _comfy_io.Image.Input( + "image", + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_IMAGE, + ), + _comfy_io.SEGS.Input( + "segs", + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_SEGS, + ), + _comfy_io.Clip.Input( + "clip", + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_CLIP, + ), + _comfy_io.Combo.Input( + "model", + options=model_choices, + default=model_choices[0], + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_MODEL, + ), + _comfy_io.String.Input( + "system_prompt", + multiline=False, + default="", + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_SYSTEM_PROMPT, + ), + _comfy_io.String.Input( + "user_prompt", + multiline=False, + default="", + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_USER_PROMPT, + ), + _comfy_io.String.Input( + "universal_positive", + multiline=False, + default="", + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_UNIVERSAL_POSITIVE, + ), + _comfy_io.Combo.Input( + "seg_image_mode", + options=list(SEG_IMAGE_MODES), + default=DEFAULT_SEG_IMAGE_MODE, + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_IMAGE_MODE, + ), + _comfy_io.Boolean.Input( + "replace_underscore", + default=True, + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_REPLACE_UNDERSCORE, + ), + _comfy_io.Boolean.Input( + "trailing_comma", + default=False, + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_TRAILING_COMMA, + ), + _comfy_io.String.Input( + "exclude_tags", + multiline=False, + default="", + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_EXCLUDE_TAGS, + ), + _comfy_io.Int.Input( + "max_tokens", + default=DEFAULT_EXTERNAL_LLM_MAX_TOKENS, + min=1, + max=MAX_EXTERNAL_LLM_MAX_TOKENS, + step=1, + tooltip=tooltips.EXTERNAL_LLM_MAX_TOKENS_INPUT, + ), + _comfy_io.Combo.Input( + "reasoning_effort", + options=list(EXTERNAL_LLM_REASONING_EFFORTS), + default=DEFAULT_EXTERNAL_LLM_REASONING_EFFORT, + tooltip=tooltips.EXTERNAL_LLM_REASONING_EFFORT_INPUT, + ), + ], + outputs=[ + _comfy_io.SEGS.Output( + "segs", + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_SEGS_OUTPUT, + ), + ConditioningBatchIO.Output( + "positive", + tooltip=tooltips.EXTERNAL_LLM_TAG_SEGS_POSITIVE_OUTPUT, + ), + ], + ) + + @classmethod + def execute( + cls, + image: object, + segs: object, + clip: Any, + model: str, + system_prompt: str, + user_prompt: str, + universal_positive: str, + seg_image_mode: str, + replace_underscore: bool, + trailing_comma: bool, + exclude_tags: str, + max_tokens: int = DEFAULT_EXTERNAL_LLM_MAX_TOKENS, + reasoning_effort: str = DEFAULT_EXTERNAL_LLM_REASONING_EFFORT, + ) -> tuple[object, object]: + """Tag existing SEGS and return aligned conditioning.""" + + result = cls._service.tag( + image=image, + segs=segs, + clip=clip, + model=model, + system_prompt=system_prompt, + user_prompt=user_prompt, + universal_positive=universal_positive, + seg_image_mode=seg_image_mode, + formatting=LLMTagFormattingControls( + replace_underscore=replace_underscore, + trailing_comma=trailing_comma, + exclude_tags=exclude_tags, + ), + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + ) + return result.segs, result.positive diff --git a/simple_syrup/runtime/external_llm_images.py b/simple_syrup/runtime/external_llm_images.py index 6a4c660..797aae9 100644 --- a/simple_syrup/runtime/external_llm_images.py +++ b/simple_syrup/runtime/external_llm_images.py @@ -12,8 +12,12 @@ from io import BytesIO import torch from PIL import Image +from ..domain.segs import Segment +from ..masking.segs_mask_ops import crop_image, crop_mask, resize_mask from ..shared.tensor_validation import validate_image_tensor +SEG_IMAGE_MODES = ("transparent mask", "black mask", "full crop") + class ExternalLLMImageEncoder: """Encode ComfyUI IMAGE tensors for OpenAI-compatible vision payloads.""" @@ -32,6 +36,45 @@ class ExternalLLMImageEncoder: return f"data:image/png;base64,{encoded}" +class ExternalLLMSegsImageEncoder: + """Encode SEG crops for OpenAI-compatible vision payloads.""" + + def encode_segment_as_data_url( + self, + image: torch.Tensor, + segment: Segment, + mode: str, + ) -> str: + """Return one SEG crop as a PNG data URL.""" + + if mode not in SEG_IMAGE_MODES: + choices = ", ".join(SEG_IMAGE_MODES) + raise ValueError(f"seg_image_mode must be one of: {choices}.") + validate_image_tensor(image) + if int(image.shape[0]) != 1: + raise ValueError("SEG crop image encoding requires one IMAGE item.") + + crop = crop_image(image.float().clamp(0.0, 1.0), segment.crop_region) + if mode == "full crop": + return _pil_to_png_data_url(_image_tensor_to_rgb_pil(crop)) + + mask = _segment_mask_for_crop( + segment=segment, + image_height=int(image.shape[1]), + image_width=int(image.shape[2]), + ) + if int(mask.shape[0]) != int(crop.shape[1]) or int(mask.shape[1]) != int( + crop.shape[2] + ): + mask = resize_mask(mask, int(crop.shape[1]), int(crop.shape[2])) + + if mode == "black mask": + masked_crop = crop * mask.unsqueeze(0).unsqueeze(-1) + return _pil_to_png_data_url(_image_tensor_to_rgb_pil(masked_crop)) + + return _pil_to_png_data_url(_crop_and_mask_to_rgba_pil(crop, mask)) + + def _image_tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image: """Convert the first BHWC image tensor item to RGB PIL image.""" @@ -45,3 +88,63 @@ def _image_tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image: else: array = np.repeat(array[..., :1], 3, axis=-1) return Image.fromarray((array * 255.0).round().astype(np.uint8)) + + +def _segment_mask_for_crop( + segment: Segment, + image_height: int, + image_width: int, +) -> torch.Tensor: + """Return the segment mask normalized to the crop region.""" + + if not isinstance(segment.cropped_mask, torch.Tensor): + raise TypeError("SEG cropped_mask must be a torch.Tensor.") + + mask = _normalize_mask_shape(segment.cropped_mask) + region = segment.crop_region + crop_height = region.height + crop_width = region.width + mask_height = int(mask.shape[0]) + mask_width = int(mask.shape[1]) + if mask_height == crop_height and mask_width == crop_width: + return mask.float().clamp(0.0, 1.0) + if mask_height == image_height and mask_width == image_width: + return crop_mask(mask, region).float().clamp(0.0, 1.0) + return resize_mask(mask, crop_height, crop_width).float().clamp(0.0, 1.0) + + +def _normalize_mask_shape(mask: torch.Tensor) -> torch.Tensor: + """Return a SEG mask as an HW tensor.""" + + working = mask.detach().cpu().float() + if working.ndim == 2: + return working + if working.ndim == 3: + return working[0] + raise ValueError("SEG cropped_mask must be an HW or BHW tensor.") + + +def _crop_and_mask_to_rgba_pil(crop: torch.Tensor, mask: torch.Tensor) -> Image.Image: + """Convert a BHWC crop and HW alpha mask to an RGBA PIL image.""" + + import numpy as np + + rgb = crop[0].detach().cpu().float().clamp(0.0, 1.0).numpy() + if rgb.shape[-1] == 1: + rgb = np.repeat(rgb, 3, axis=-1) + elif rgb.shape[-1] >= 3: + rgb = rgb[..., :3] + else: + rgb = np.repeat(rgb[..., :1], 3, axis=-1) + alpha = mask.detach().cpu().float().clamp(0.0, 1.0).numpy() + rgba = np.concatenate((rgb, alpha[..., None]), axis=-1) + return Image.fromarray((rgba * 255.0).round().astype(np.uint8), mode="RGBA") + + +def _pil_to_png_data_url(image: Image.Image) -> str: + """Return a PNG data URL for a PIL image.""" + + buffer = BytesIO() + image.save(buffer, format="PNG") + encoded = base64.b64encode(buffer.getvalue()).decode("ascii") + return f"data:image/png;base64,{encoded}" diff --git a/simple_syrup/services/external_llm_prompt_service.py b/simple_syrup/services/external_llm_prompt_service.py index 41cb8fa..75e82c1 100644 --- a/simple_syrup/services/external_llm_prompt_service.py +++ b/simple_syrup/services/external_llm_prompt_service.py @@ -228,6 +228,30 @@ class ExternalLLMPromptService: ) -> str: """Generate an assistant response for the supplied prompt pair.""" + return self.generate_with_image_data_url( + model=model, + system_prompt=system_prompt, + user_prompt=user_prompt, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + image_data_url=( + None + if image is None + else self._image_encoder.encode_first_image_as_data_url(image) + ), + ) + + def generate_with_image_data_url( + 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_data_url: str | None = None, + ) -> str: + """Generate an assistant response with a pre-encoded optional image.""" + selected_model = self._resolve_model_for_execution(model) external = self._settings_repository.load().external_llm @@ -250,11 +274,7 @@ class ExternalLLMPromptService: user_prompt=user_prompt, max_tokens=max_tokens, reasoning_effort=reasoning_effort, - image_data_url=( - None - if image is None - else self._image_encoder.encode_first_image_as_data_url(image) - ), + image_data_url=image_data_url, ) response = self._client.create_chat_completion( external.base_url, diff --git a/simple_syrup/services/tag_segs_with_external_llm_service.py b/simple_syrup/services/tag_segs_with_external_llm_service.py new file mode 100644 index 0000000..2db45df --- /dev/null +++ b/simple_syrup/services/tag_segs_with_external_llm_service.py @@ -0,0 +1,295 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Application service for external-LLM tagging of existing SEGS.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Protocol + +import torch + +from ..domain.conditioning_batch import ConditioningBatch +from ..domain.external_llm import ( + DEFAULT_EXTERNAL_LLM_MAX_TOKENS, + DEFAULT_EXTERNAL_LLM_REASONING_EFFORT, +) +from ..domain.prompt_composition import prefix_prompt +from ..domain.segs import ( + ImpactSegs, + NativeSegs, + Segment, + coerce_segs, + to_impact_compatible_segs, +) +from ..masking.segs_mask_ops import validate_single_image +from ..runtime.conditioning_encoding import ComfyConditioningEncoder +from ..runtime.external_llm_images import ExternalLLMSegsImageEncoder +from ..runtime.progress import ProgressReporter, create_comfy_progress +from ..shared.logging import get_logger +from .external_llm_prompt_service import ExternalLLMPromptService + +LOGGER = get_logger(__name__) +OPERATION = "Tag SEGS w/ External LLM" + + +@dataclass(frozen=True) +class LLMTagFormattingControls: + """Store formatting controls for external LLM tag responses.""" + + replace_underscore: bool = True + trailing_comma: bool = False + exclude_tags: str = "" + + +class ExternalLLMGenerationBoundary(Protocol): + """External LLM provider execution boundary.""" + + def model_choices(self) -> list[str]: + """Return cached provider model choices for Comfy dropdowns.""" + + def generate_with_image_data_url( + 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_data_url: str | None = None, + ) -> str: + """Return one assistant response for a pre-encoded image.""" + + +class SegmentImageEncodingBoundary(Protocol): + """Encode ordered SEG crops as LLM vision images.""" + + def encode_segment_as_data_url( + self, + image: torch.Tensor, + segment: Segment, + mode: str, + ) -> str: + """Return one SEG crop image data URL.""" + + +class ConditioningEncodingBoundary(Protocol): + """Encode ordered prompts into a conditioning batch.""" + + def encode_batch(self, clip: Any, chunks: tuple[str, ...]) -> ConditioningBatch: + """Return conditioning entries in prompt order.""" + + +@dataclass(frozen=True) +class TagSEGSWithExternalLLMResult: + """Return unchanged SEGS and aligned external-LLM conditioning.""" + + segs: ImpactSegs + positive: ConditioningBatch + + +class TagSEGSWithExternalLLMService: + """Caption provided SEGS crops with an external LLM and encode conditioning.""" + + def __init__( + self, + llm: ExternalLLMGenerationBoundary | None = None, + image_encoder: SegmentImageEncodingBoundary | None = None, + conditioning_encoder: ConditioningEncodingBoundary | None = None, + progress_factory: Callable[[int], ProgressReporter] | None = None, + ) -> None: + """Create the service with injectable runtime boundaries.""" + + self._llm = llm or ExternalLLMPromptService() + self._image_encoder = image_encoder or ExternalLLMSegsImageEncoder() + self._conditioning_encoder = conditioning_encoder or ComfyConditioningEncoder() + self._progress_factory = progress_factory or create_comfy_progress + + def model_choices(self) -> list[str]: + """Return cached provider model choices for Comfy dropdowns.""" + + return self._llm.model_choices() + + def tag( + self, + image: object, + segs: object, + clip: Any, + model: str, + system_prompt: str, + user_prompt: str, + universal_positive: str, + seg_image_mode: str, + formatting: LLMTagFormattingControls, + max_tokens: int = DEFAULT_EXTERNAL_LLM_MAX_TOKENS, + reasoning_effort: str = DEFAULT_EXTERNAL_LLM_REASONING_EFFORT, + ) -> TagSEGSWithExternalLLMResult: + """Return original SEGS plus LLM-derived conditioning in segment order.""" + + image_tensor = validate_single_image(image, OPERATION) + native_segs = coerce_segs(segs) + self._validate_segs_target_image(native_segs, image_tensor) + _header, segments = native_segs + if not segments: + raise ValueError("No SEGS were provided for Tag SEGS w/ External LLM.") + + progress = self._progress_factory(len(segments) + 2) + progress.update(1) + prompts: list[str] = [] + for segment in segments: + prompts.append( + self._prompt_for_segment( + image=image_tensor, + segment=segment, + model=model, + system_prompt=system_prompt, + user_prompt=user_prompt, + universal_positive=universal_positive, + seg_image_mode=seg_image_mode, + formatting=formatting, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + ) + ) + progress.update(1) + + positive = self._conditioning_encoder.encode_batch(clip, tuple(prompts)) + progress.update(1) + if len(positive.entries) != len(segments): + raise ValueError( + "Conditioning encoder returned " + f"{len(positive.entries)} entries for {len(segments)} SEGS." + ) + + LOGGER.info( + "Tag SEGS w/ External LLM pass completed", + extra={ + "operation": "tag_segs_with_external_llm", + "segment_count": len(segments), + "external_llm_model": model, + "seg_image_mode": seg_image_mode, + "universal_positive_present": bool(universal_positive.strip()), + "replace_underscore": formatting.replace_underscore, + "trailing_comma": formatting.trailing_comma, + "exclude_tags_present": bool(formatting.exclude_tags.strip()), + }, + ) + return TagSEGSWithExternalLLMResult( + segs=to_impact_compatible_segs(native_segs), + positive=positive, + ) + + def _prompt_for_segment( + self, + image: torch.Tensor, + segment: Segment, + model: str, + system_prompt: str, + user_prompt: str, + universal_positive: str, + seg_image_mode: str, + formatting: LLMTagFormattingControls, + max_tokens: int, + reasoning_effort: str, + ) -> str: + """Generate, format, and prefix one segment prompt.""" + + image_data_url = self._image_encoder.encode_segment_as_data_url( + image, + segment, + seg_image_mode, + ) + response = self._llm.generate_with_image_data_url( + model=model, + system_prompt=system_prompt, + user_prompt=user_prompt, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + image_data_url=image_data_url, + ) + prompt = format_external_llm_tags(response, formatting) + return prefix_prompt(universal_positive, prompt) + + def _validate_segs_target_image( + self, + segs: NativeSegs, + image: torch.Tensor, + ) -> None: + """Reject SEGS that cannot be cropped from the provided image.""" + + header, segments = segs + image_height = int(image.shape[1]) + image_width = int(image.shape[2]) + if header != (image_height, image_width): + raise ValueError( + f"{OPERATION} requires SEGS header dimensions to match the image: " + f"SEGS is {header[0]}x{header[1]}, image is " + f"{image_height}x{image_width}." + ) + for index, segment in enumerate(segments): + _validate_segment_crop(segment, index, image_height, image_width) + + +def format_external_llm_tags( + response: str, + controls: LLMTagFormattingControls, +) -> str: + """Format one external LLM response into comma-separated prompt tags.""" + + stripped = response.strip() + if not stripped: + raise ValueError("External LLM returned an empty response for a SEG.") + + excluded = _excluded_tags(controls) + tags: list[str] = [] + for chunk in stripped.split(","): + tag = _normalize_tag(chunk, controls.replace_underscore) + if not tag or tag.lower() in excluded: + continue + tags.append(tag) + if not tags: + raise ValueError("External LLM response had no usable tags after exclusions.") + + prompt = ", ".join(tags) + if controls.trailing_comma and not prompt.endswith(","): + return f"{prompt}," + return prompt + + +def _excluded_tags(controls: LLMTagFormattingControls) -> set[str]: + """Return normalized excluded tag names.""" + + return { + tag.lower() + for raw_tag in controls.exclude_tags.split(",") + if (tag := _normalize_tag(raw_tag, controls.replace_underscore)) + } + + +def _normalize_tag(value: str, replace_underscore: bool) -> str: + """Return the prompt-facing form of one tag-like response chunk.""" + + tag = value.strip() + if replace_underscore: + return tag.replace("_", " ") + return tag + + +def _validate_segment_crop( + segment: Segment, + index: int, + image_height: int, + image_width: int, +) -> None: + """Reject a segment crop region that falls outside the image.""" + + region = segment.crop_region + if region.right <= image_width and region.bottom <= image_height: + return + raise ValueError( + f"{OPERATION} SEG {index} ('{segment.label}') crop_region must fit " + f"inside the image; got ({region.left}, {region.top}, {region.right}, " + f"{region.bottom}) for image {image_height}x{image_width}." + ) diff --git a/tests/test_external_llm_prompt_service.py b/tests/test_external_llm_prompt_service.py index f3ea272..83e8e03 100644 --- a/tests/test_external_llm_prompt_service.py +++ b/tests/test_external_llm_prompt_service.py @@ -401,6 +401,24 @@ def test_generate_attaches_encoded_image_when_supplied() -> None: assert client.requests[0].image_data_url == "data:image/png;base64,abc" +def test_generate_with_image_data_url_forwards_preencoded_image() -> None: + """SEG callers can supply a prebuilt image data URL.""" + + client = FakeClient() + service = configured_service(client=client) + + assert ( + service.generate_with_image_data_url( + model="model-a", + system_prompt="system", + user_prompt="user", + image_data_url="data:image/png;base64,seg", + ) + == "assistant response" + ) + assert client.requests[0].image_data_url == "data:image/png;base64,seg" + + def configured_service( client: FakeClient | None = None, image_encoder: FakeImageEncoder | None = None, diff --git a/tests/test_external_llm_segs_images.py b/tests/test_external_llm_segs_images.py new file mode 100644 index 0000000..5ceeb7d --- /dev/null +++ b/tests/test_external_llm_segs_images.py @@ -0,0 +1,160 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for external LLM SEG crop image encoding.""" + +from __future__ import annotations + +import base64 +from io import BytesIO +from typing import cast + +import pytest +import torch +from PIL import Image + +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment +from simple_syrup.runtime.external_llm_images import ExternalLLMSegsImageEncoder + + +def test_segs_image_encoder_returns_transparent_mask_png() -> None: + """Transparent mode hides outside-mask pixels with PNG alpha.""" + + segment = _segment(torch.tensor([[1.0, 0.0], [0.0, 1.0]])) + + encoded = ExternalLLMSegsImageEncoder().encode_segment_as_data_url( + _image(), + segment, + "transparent mask", + ) + + image = _decode_png(encoded) + assert image.mode == "RGBA" + assert image.size == (2, 2) + assert _rgba_pixel(image, 0, 0)[3] == 255 + assert _rgba_pixel(image, 1, 0)[3] == 0 + assert _rgba_pixel(image, 0, 1)[3] == 0 + assert _rgba_pixel(image, 1, 1)[3] == 255 + + +def test_segs_image_encoder_returns_black_mask_png() -> None: + """Black mode zeros outside-mask pixels and keeps RGB output.""" + + segment = _segment(torch.tensor([[1.0, 0.0], [0.0, 1.0]])) + + encoded = ExternalLLMSegsImageEncoder().encode_segment_as_data_url( + _image(), + segment, + "black mask", + ) + + image = _decode_png(encoded) + assert image.mode == "RGB" + assert image.size == (2, 2) + assert image.getpixel((1, 0)) == (0, 0, 0) + assert image.getpixel((0, 1)) == (0, 0, 0) + assert image.getpixel((0, 0)) != (0, 0, 0) + assert image.getpixel((1, 1)) != (0, 0, 0) + + +def test_segs_image_encoder_returns_full_crop_png() -> None: + """Full crop mode preserves the whole crop rectangle.""" + + segment = _segment(torch.zeros((2, 2))) + + encoded = ExternalLLMSegsImageEncoder().encode_segment_as_data_url( + _image(), + segment, + "full crop", + ) + + image = _decode_png(encoded) + assert image.mode == "RGB" + assert image.size == (2, 2) + assert image.getpixel((0, 0)) != (0, 0, 0) + assert image.getpixel((1, 0)) != (0, 0, 0) + assert image.getpixel((0, 1)) != (0, 0, 0) + assert image.getpixel((1, 1)) != (0, 0, 0) + + +def test_segs_image_encoder_accepts_full_image_masks() -> None: + """Full-image SEG masks are cropped to the SEG crop region.""" + + full_mask = torch.zeros((4, 4), dtype=torch.float32) + full_mask[1, 1] = 1.0 + full_mask[2, 2] = 1.0 + segment = _segment(full_mask) + + encoded = ExternalLLMSegsImageEncoder().encode_segment_as_data_url( + _image(), + segment, + "transparent mask", + ) + + image = _decode_png(encoded) + assert [_rgba_pixel(image, x, y)[3] for y in range(2) for x in range(2)] == [ + 255, + 0, + 0, + 255, + ] + + +def test_segs_image_encoder_rejects_invalid_masks() -> None: + """SEG masks must be HW or BHW tensors.""" + + segment = _segment(torch.zeros((1, 1, 1, 1), dtype=torch.float32)) + + with pytest.raises(ValueError, match="HW or BHW"): + ExternalLLMSegsImageEncoder().encode_segment_as_data_url( + _image(), + segment, + "transparent mask", + ) + + +def test_segs_image_encoder_rejects_unknown_mode() -> None: + """SEG image mode is restricted to the node combo choices.""" + + with pytest.raises(ValueError, match="seg_image_mode"): + ExternalLLMSegsImageEncoder().encode_segment_as_data_url( + _image(), + _segment(torch.ones((2, 2), dtype=torch.float32)), + "white mask", + ) + + +def _decode_png(data_url: str) -> Image.Image: + """Decode a PNG data URL into a PIL image.""" + + assert data_url.startswith("data:image/png;base64,") + payload = data_url.removeprefix("data:image/png;base64,") + return Image.open(BytesIO(base64.b64decode(payload))) + + +def _rgba_pixel(image: Image.Image, x: int, y: int) -> tuple[int, int, int, int]: + """Return one RGBA pixel with a precise test type.""" + + return cast(tuple[int, int, int, int], image.getpixel((x, y))) + + +def _segment(mask: torch.Tensor) -> Segment: + """Return a SEG that crops a stable two-by-two image region.""" + + return Segment( + cropped_image=None, + cropped_mask=mask, + confidence=1.0, + crop_region=CropRegion(1, 1, 3, 3), + bbox=BoundingBox(1, 1, 3, 3), + label="seg", + ) + + +def _image() -> torch.Tensor: + """Return a deterministic BHWC image with no black crop pixels.""" + + return ( + torch.arange(1, 4 * 4 * 3 + 1, dtype=torch.float32).reshape(1, 4, 4, 3) / 255.0 + ) diff --git a/tests/test_registration.py b/tests/test_registration.py index a15eda0..9591e92 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -44,6 +44,7 @@ BASE_NODE_IDS = [ "SimpleSyrup.SimpleLoadAnima", "SimpleSyrup.SimpleLoadCheckpoint", "SimpleSyrup.SimpleVAEEncode", + "SimpleSyrup.TagSEGSWithExternalLLM", "SimpleSyrup.TagSEGSWithWD14", "SimpleSyrup.TileAndTagSEGS", "SimpleSyrup.UpscaleLatentFromImage", diff --git a/tests/test_tag_segs_with_external_llm_service.py b/tests/test_tag_segs_with_external_llm_service.py new file mode 100644 index 0000000..c76be1d --- /dev/null +++ b/tests/test_tag_segs_with_external_llm_service.py @@ -0,0 +1,360 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for external-LLM tagging of existing SEGS.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.services.tag_segs_with_external_llm_service import ( + LLMTagFormattingControls, + TagSEGSWithExternalLLMService, +) + +DEFAULT_CROP_REGION = CropRegion(1, 1, 3, 3) + + +def test_service_preserves_segs_llm_prompt_and_conditioning_order( + caplog: pytest.LogCaptureFixture, +) -> None: + """Existing SEGS, LLM calls, formatted prompts, and conditioning stay aligned.""" + + progress = _ProgressRecorder() + llm = _FakeLLM(("blue_hair, smile, bad_tag", "green_eyes")) + image_encoder = _FakeImageEncoder() + conditioning_encoder = _FakeConditioningEncoder() + service = TagSEGSWithExternalLLMService( + llm=llm, + image_encoder=image_encoder, + conditioning_encoder=conditioning_encoder, + progress_factory=lambda _total: progress, + ) + segs = _native_segs(("first", "second")) + + with caplog.at_level(logging.INFO, logger="simple_syrup"): + result = service.tag( + image=_image(), + segs=segs, + clip="clip", + model="vision-model", + system_prompt="system", + user_prompt="user", + universal_positive="masterpiece", + seg_image_mode="transparent mask", + formatting=LLMTagFormattingControls( + replace_underscore=True, + trailing_comma=True, + exclude_tags="bad tag", + ), + max_tokens=64, + reasoning_effort="off", + ) + + assert [segment.label for segment in result.segs[1]] == ["first", "second"] + assert image_encoder.calls == ( + ("first", "transparent mask"), + ("second", "transparent mask"), + ) + assert [call["image_data_url"] for call in llm.calls] == [ + "data:image/png;base64,first", + "data:image/png;base64,second", + ] + assert [call["model"] for call in llm.calls] == ["vision-model", "vision-model"] + assert [call["max_tokens"] for call in llm.calls] == [64, 64] + assert [call["reasoning_effort"] for call in llm.calls] == ["off", "off"] + assert conditioning_encoder.chunks == ( + "masterpiece, blue hair, smile,", + "masterpiece, green eyes,", + ) + assert result.positive.entries == ( + "clip:masterpiece, blue hair, smile,", + "clip:masterpiece, green eyes,", + ) + assert progress.updates == [1, 1, 1, 1] + record = caplog.records[-1] + assert record.__dict__["operation"] == "tag_segs_with_external_llm" + assert record.__dict__["segment_count"] == 2 + assert record.__dict__["external_llm_model"] == "vision-model" + assert record.__dict__["seg_image_mode"] == "transparent mask" + assert record.__dict__["universal_positive_present"] is True + + +def test_service_preserves_underscores_when_requested() -> None: + """Formatting can keep booru-style underscores.""" + + conditioning_encoder = _FakeConditioningEncoder() + service = _service( + llm=_FakeLLM(("blue_hair, smile",)), + conditioning_encoder=conditioning_encoder, + ) + + service.tag( + image=_image(), + segs=_native_segs(("first",)), + clip="clip", + model="vision-model", + system_prompt="system", + user_prompt="user", + universal_positive="", + seg_image_mode="black mask", + formatting=LLMTagFormattingControls(replace_underscore=False), + ) + + assert conditioning_encoder.chunks == ("blue_hair, smile",) + + +def test_service_rejects_empty_segs() -> None: + """External LLM tagging needs at least one SEG to tag.""" + + with pytest.raises(ValueError, match="No SEGS"): + _service().tag( + image=_image(), + segs=((4, 4), ()), + clip="clip", + model="vision-model", + system_prompt="system", + user_prompt="user", + universal_positive="", + seg_image_mode="transparent mask", + formatting=LLMTagFormattingControls(), + ) + + +def test_service_rejects_segs_image_header_mismatch() -> None: + """SEGS must describe the source image dimensions.""" + + with pytest.raises(ValueError, match="SEGS is 8x4, image is 4x4"): + _service().tag( + image=_image(), + segs=((8, 4), _native_segs(("first",))[1]), + clip="clip", + model="vision-model", + system_prompt="system", + user_prompt="user", + universal_positive="", + seg_image_mode="transparent mask", + formatting=LLMTagFormattingControls(), + ) + + +def test_service_rejects_crop_regions_outside_image() -> None: + """SEG crop regions must fit inside the connected source image.""" + + with pytest.raises(ValueError, match="crop_region must fit"): + _service().tag( + image=_image(), + segs=_native_segs(("outside",), crop_region=CropRegion(3, 3, 5, 5)), + clip="clip", + model="vision-model", + system_prompt="system", + user_prompt="user", + universal_positive="", + seg_image_mode="transparent mask", + formatting=LLMTagFormattingControls(), + ) + + +def test_service_rejects_empty_llm_responses() -> None: + """Empty provider responses cannot produce usable regional prompts.""" + + with pytest.raises(ValueError, match="empty response"): + _service(llm=_FakeLLM((" ",))).tag( + image=_image(), + segs=_native_segs(("first",)), + clip="clip", + model="vision-model", + system_prompt="system", + user_prompt="user", + universal_positive="", + seg_image_mode="transparent mask", + formatting=LLMTagFormattingControls(), + ) + + +def test_service_rejects_responses_removed_by_exclusions() -> None: + """Exclusion filtering must not silently create blank prompts.""" + + with pytest.raises(ValueError, match="no usable tags"): + _service(llm=_FakeLLM(("bad_tag",))).tag( + image=_image(), + segs=_native_segs(("first",)), + clip="clip", + model="vision-model", + system_prompt="system", + user_prompt="user", + universal_positive="", + seg_image_mode="transparent mask", + formatting=LLMTagFormattingControls(exclude_tags="bad tag"), + ) + + +def test_service_rejects_conditioning_count_mismatch() -> None: + """Conditioning output count must stay aligned to SEGS count.""" + + with pytest.raises(ValueError, match="returned 1 entries for 2 SEGS"): + _service( + llm=_FakeLLM(("first", "second")), + conditioning_encoder=_ShortConditioningEncoder(), + ).tag( + image=_image(), + segs=_native_segs(("first", "second")), + clip="clip", + model="vision-model", + system_prompt="system", + user_prompt="user", + universal_positive="", + seg_image_mode="transparent mask", + formatting=LLMTagFormattingControls(), + ) + + +class _FakeLLM: + """Return ordered external LLM responses.""" + + def __init__(self, responses: tuple[str, ...] = ("tag",)) -> None: + """Store fixed responses.""" + + self.responses = responses + self.calls: list[dict[str, object]] = [] + + def model_choices(self) -> list[str]: + """Return deterministic model choices.""" + + return ["vision-model"] + + def generate_with_image_data_url( + self, + model: str, + system_prompt: str, + user_prompt: str, + max_tokens: int = 1024, + reasoning_effort: str = "default", + image_data_url: str | None = None, + ) -> str: + """Capture one LLM call and return its configured response.""" + + self.calls.append( + { + "model": model, + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "max_tokens": max_tokens, + "reasoning_effort": reasoning_effort, + "image_data_url": image_data_url, + } + ) + return self.responses[len(self.calls) - 1] + + +class _FakeImageEncoder: + """Return visible data URLs for SEG crops.""" + + def __init__(self) -> None: + """Initialize captured calls.""" + + self.calls: tuple[tuple[str, str], ...] = () + + def encode_segment_as_data_url( + self, + image: torch.Tensor, + segment: Segment, + mode: str, + ) -> str: + """Record one SEG image encoding call.""" + + assert tuple(image.shape) == (1, 4, 4, 3) + self.calls = (*self.calls, (segment.label, mode)) + return f"data:image/png;base64,{segment.label}" + + +class _FakeConditioningEncoder: + """Return visible conditioning entries for prompt chunks.""" + + def __init__(self) -> None: + """Initialize captured chunks.""" + + self.chunks: tuple[str, ...] = () + + def encode_batch(self, clip: Any, chunks: tuple[str, ...]) -> ConditioningBatch: + """Encode prompts as simple strings.""" + + self.chunks = chunks + return ConditioningBatch(tuple(f"{clip}:{chunk}" for chunk in chunks)) + + +class _ShortConditioningEncoder: + """Return too few conditioning entries.""" + + def encode_batch(self, clip: Any, chunks: tuple[str, ...]) -> ConditioningBatch: + """Encode only the first prompt chunk.""" + + _ = clip + return ConditioningBatch((chunks[0],)) + + +class _ProgressRecorder: + """Record service progress updates.""" + + def __init__(self) -> None: + """Initialize captured update values.""" + + self.updates: list[int] = [] + + def update(self, value: int) -> None: + """Record one progress advance.""" + + self.updates.append(value) + + +def _service( + llm: _FakeLLM | None = None, + conditioning_encoder: _FakeConditioningEncoder + | _ShortConditioningEncoder + | None = None, +) -> TagSEGSWithExternalLLMService: + """Create a service with fake boundaries.""" + + return TagSEGSWithExternalLLMService( + llm=llm or _FakeLLM(), + image_encoder=_FakeImageEncoder(), + conditioning_encoder=conditioning_encoder or _FakeConditioningEncoder(), + ) + + +def _native_segs( + labels: tuple[str, ...], + crop_region: CropRegion = DEFAULT_CROP_REGION, +) -> NativeSegs: + """Create native SEGS with stable crop regions.""" + + segments = tuple( + Segment( + cropped_image=None, + cropped_mask=torch.ones((crop_region.height, crop_region.width)), + confidence=1.0, + crop_region=crop_region, + bbox=BoundingBox( + crop_region.left, + crop_region.top, + crop_region.right, + crop_region.bottom, + ), + label=label, + ) + for label in labels + ) + return (4, 4), segments + + +def _image() -> torch.Tensor: + """Return a small deterministic BHWC image.""" + + return torch.arange(4 * 4 * 3, dtype=torch.float32).reshape(1, 4, 4, 3) / 255.0 diff --git a/tests/test_tag_segs_with_external_llm_v3_node.py b/tests/test_tag_segs_with_external_llm_v3_node.py new file mode 100644 index 0000000..364c93b --- /dev/null +++ b/tests/test_tag_segs_with_external_llm_v3_node.py @@ -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 + +"""Tests for the Tag SEGS w/ External LLM Comfy v3 node.""" + +from __future__ import annotations + +from typing import Any + +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, ImpactSegs, Segment +from simple_syrup.nodes_v3.tag_segs_with_external_llm import ( + TagSEGSWithExternalLLMV3, +) +from simple_syrup.services.tag_segs_with_external_llm_service import ( + LLMTagFormattingControls, + TagSEGSWithExternalLLMResult, +) + + +def test_tag_segs_with_external_llm_v3_schema(monkeypatch: Any) -> None: + """The v3 schema exposes the external LLM SEGS tagging contract.""" + + monkeypatch.setattr(TagSEGSWithExternalLLMV3, "_service", _FakeService()) + + schema = TagSEGSWithExternalLLMV3.define_schema() + + assert schema.node_id == "SimpleSyrup.TagSEGSWithExternalLLM" + assert schema.display_name == "Tag SEGS w/ External LLM" + assert schema.category == "SimpleSyrup/Detailing" + assert [input_item.id for input_item in schema.inputs] == [ + "image", + "segs", + "clip", + "model", + "system_prompt", + "user_prompt", + "universal_positive", + "seg_image_mode", + "replace_underscore", + "trailing_comma", + "exclude_tags", + "max_tokens", + "reasoning_effort", + ] + assert schema.inputs[0].io_type == "IMAGE" + assert schema.inputs[1].io_type == "SEGS" + assert schema.inputs[2].io_type == "CLIP" + assert schema.inputs[3].options == ["vision-model"] + assert schema.inputs[4].default == "" + assert schema.inputs[5].default == "" + seg_image_mode = schema.inputs[7] + assert seg_image_mode.io_type == "COMBO" + assert seg_image_mode.options == ["transparent mask", "black mask", "full crop"] + assert seg_image_mode.default == "transparent mask" + assert schema.inputs[8].default is True + assert schema.inputs[9].default is False + assert schema.inputs[10].default == "" + assert schema.inputs[11].default == 1024 + assert schema.inputs[12].options == ["default", "high", "medium", "low", "off"] + assert [output.id for output in schema.outputs] == ["segs", "positive"] + assert [output.io_type for output in schema.outputs] == [ + "SEGS", + "CONDITIONING_BATCH", + ] + + +def test_tag_segs_with_external_llm_v3_execute_forwards_to_service( + monkeypatch: Any, +) -> None: + """The v3 node delegates execution to the service.""" + + service = _FakeService() + monkeypatch.setattr(TagSEGSWithExternalLLMV3, "_service", service) + image = torch.zeros((1, 8, 8, 3)) + segs: ImpactSegs = ((8, 8), []) + + output_segs, positive = TagSEGSWithExternalLLMV3.execute( + image=image, + segs=segs, + clip="clip", + model="vision-model", + system_prompt="system", + user_prompt="user", + universal_positive="masterpiece", + seg_image_mode="black mask", + replace_underscore=False, + trailing_comma=True, + exclude_tags="bad tag", + max_tokens=128, + reasoning_effort="off", + ) + + assert output_segs is service.result.segs + assert positive is service.result.positive + assert service.call["image"] is image + assert service.call["segs"] is segs + assert service.call["clip"] == "clip" + assert service.call["model"] == "vision-model" + assert service.call["system_prompt"] == "system" + assert service.call["user_prompt"] == "user" + assert service.call["universal_positive"] == "masterpiece" + assert service.call["seg_image_mode"] == "black mask" + assert service.call["max_tokens"] == 128 + assert service.call["reasoning_effort"] == "off" + formatting = service.call["formatting"] + assert isinstance(formatting, LLMTagFormattingControls) + assert formatting.replace_underscore is False + assert formatting.trailing_comma is True + assert formatting.exclude_tags == "bad tag" + + +class _FakeService: + """Capture v3 node service calls.""" + + def __init__(self) -> None: + """Create a fake service result.""" + + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((8, 8)), + confidence=1.0, + crop_region=CropRegion(0, 0, 8, 8), + bbox=BoundingBox(0, 0, 8, 8), + label="seg_001", + ) + self.result = TagSEGSWithExternalLLMResult( + segs=((8, 8), [segment]), + positive=ConditioningBatch(("encoded",)), + ) + self.call: dict[str, object] = {} + + def model_choices(self) -> list[str]: + """Return deterministic model choices.""" + + return ["vision-model"] + + def tag(self, **kwargs: object) -> TagSEGSWithExternalLLMResult: + """Return a fixed result and remember provided inputs.""" + + self.call = kwargs + return self.result From 6cf67e98aa7c329c76087280ee0e0838ce685972 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Tue, 2 Jun 2026 11:25:53 -0400 Subject: [PATCH 14/40] fix(tiled-diffusion): clamp overlap for small latents --- simple_syrup/domain/tiled_diffusion.py | 3 ++- tests/test_tiled_diffusion_domain.py | 27 ++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/simple_syrup/domain/tiled_diffusion.py b/simple_syrup/domain/tiled_diffusion.py index af3c894..e9e2951 100644 --- a/simple_syrup/domain/tiled_diffusion.py +++ b/simple_syrup/domain/tiled_diffusion.py @@ -73,7 +73,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, diff --git a/tests/test_tiled_diffusion_domain.py b/tests/test_tiled_diffusion_domain.py index 1aa3dc9..b8a8fcd 100644 --- a/tests/test_tiled_diffusion_domain.py +++ b/tests/test_tiled_diffusion_domain.py @@ -50,7 +50,7 @@ def test_validate_tiled_diffusion_mode_rejects_unsupported_mode() -> None: def test_plan_clamps_tile_size_to_latent_dimensions() -> None: - """Requested tiles larger than the latent are clamped to latent dimensions.""" + """Requested tiles and overlap larger than the latent use safe dimensions.""" plan = build_tiled_diffusion_plan( latent_width=12, @@ -63,13 +63,13 @@ def test_plan_clamps_tile_size_to_latent_dimensions() -> None: assert plan.tile_width == 12 assert plan.tile_height == 8 - assert plan.overlap == 48 + assert plan.overlap == 4 assert plan.tiles == (LatentTile(0, 0, 12, 8),) assert not tile_is_splittable(12, 8, 96, 96, 48) def test_plan_clamps_overlap_against_requested_tile_dimensions() -> None: - """Overlap clamps against requested tile dimensions.""" + """Overlap clamps against effective tile dimensions.""" plan = build_tiled_diffusion_plan( latent_width=8, @@ -82,7 +82,26 @@ def test_plan_clamps_overlap_against_requested_tile_dimensions() -> None: assert plan.tile_width == 8 assert plan.tile_height == 8 - assert plan.overlap == 92 + assert plan.overlap == 4 + + +def test_plan_uses_single_tile_for_small_latent_with_default_detailer_overlap() -> None: + """Small detailer latents use one full tile instead of a zero-stride grid.""" + + plan = build_tiled_diffusion_plan( + latent_width=16, + latent_height=30, + tile_width=128, + tile_height=128, + overlap=16, + tile_batch_size=4, + ) + + assert plan.tile_width == 16 + assert plan.tile_height == 30 + assert plan.overlap == 12 + assert plan.tiles == (LatentTile(0, 0, 16, 30),) + assert not tile_is_splittable(16, 30, 128, 128, 16) def test_plan_generates_row_major_symmetric_tiles() -> None: From 561ba3409ff3674666b71a3d3645f6f9ee458ace Mon Sep 17 00:00:00 2001 From: Daisy Date: Tue, 2 Jun 2026 15:36:25 +0000 Subject: [PATCH 15/40] 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)) --- CHANGELOG.md | 13 +++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- pyproject.toml | 2 +- simple_syrup/__init__.py | 2 +- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4648c5b..7bbb76a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# [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)) + # [1.3.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.2.0...v1.3.0) (2026-05-26) diff --git a/package-lock.json b/package-lock.json index db4a37d..b0315f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-syrup-comfyui", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-syrup-comfyui", - "version": "1.3.0", + "version": "1.4.0", "license": "AGPL-3.0-or-later", "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/package.json b/package.json index 711725a..eb67bbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-syrup-comfyui", - "version": "1.3.0", + "version": "1.4.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index ea19593..d19e7fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "SimpleSyrup" description = "Workflow-focused ComfyUI extensions for image generation." -version = "1.3.0" +version = "1.4.0" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] requires-python = ">=3.11" diff --git a/simple_syrup/__init__.py b/simple_syrup/__init__.py index 995b29f..6fe1aa9 100644 --- a/simple_syrup/__init__.py +++ b/simple_syrup/__init__.py @@ -6,6 +6,6 @@ from __future__ import annotations -__version__ = "1.3.0" +__version__ = "1.4.0" __all__: list[str] = ["__version__"] From 60fff8e9b8ada75761af3fca84c1e6b2148a8b38 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sat, 6 Jun 2026 14:35:41 -0400 Subject: [PATCH 16/40] docs(readme): add project badges --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index aa3dadf..dce7adc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # SimpleSyrup +[![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL--3.0--or--later-blue.svg)](LICENSE) [![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/) + **SimpleSyrup** is a ComfyUI node pack that grew out of moving my A1111/WebUI image workflows into ComfyUI graphs. 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. From 41c0f4615dcf9e380aabf7db59162cea564100dc Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sat, 6 Jun 2026 15:54:14 -0400 Subject: [PATCH 17/40] docs(readme): document exported nodes and settings --- README.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dce7adc..c9a3748 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,11 @@ SimpleSyrup pulls from a few different places: - 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. +- Tile, tag, and guide workflows that can generate conditioning from WD14 or a configured external vision LLM. - 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. +- Normal and tiled VAE encode/decode option nodes with spatial and temporal tiling controls. +- Expandable SEGS and conditioning batch helpers for larger regional graphs. - 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. @@ -59,6 +61,7 @@ The detailer nodes are modeled heavily on **ComfyUI Impact Pack**. Detectors cre - **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. +- **Batch SEGS** combines multiple SEGS inputs into one ordered SEGS payload. - **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. @@ -73,6 +76,9 @@ Those prompts become an ordered `CONDITIONING_BATCH`, so prompt 1 stays matched - **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. +- **Batch Region Conditioning** combines normal `CONDITIONING` values and existing `CONDITIONING_BATCH` values into one ordered regional batch. +- **Encode Prompt Batch w/ Prompt Control** is exported when Prompt Control is installed. It keeps the `[SEP]` batching workflow while letting Prompt Control handle prompt scheduling and encoding. +- **Schedule & Encode Prompts** is also exported when Prompt Control is installed. It schedules Prompt Control LoRA tags, encodes positive and negative prompts, and returns normal conditioning or SimpleSyrup batches depending on whether `[SEP]` is used. ### Tile, Tag, and Guide @@ -82,6 +88,15 @@ It splits an image into deterministic tile SEGS, crops each tile, runs WD14 tagg That is the kind of thing that is easy to do once by hand and annoying to keep correct in a real graph. +- **Tag SEGS w/ WD14** tags existing SEGS crops with a connected WD14 tagger and returns conditioning aligned to the original SEGS order. +- **Tag SEGS w/ External LLM** sends each SEG crop to a configured vision-capable external LLM, formats the returned tags, and returns aligned conditioning for detail or regional workflows. + +### External LLM Prompting + +**External LLM Prompt** sends system and user prompts to a configured OpenAI-compatible provider and returns the assistant response as text. It can also include the first image from an optional image input when the selected provider model supports vision. + +The external LLM nodes use the endpoint and API key configured in SimpleSyrup settings. + ### Tiled Sampling **KSampler (Tiled Diffusion)** is a KSampler-style node with selectable **MultiDiffusion** and **Mixture of Diffusers** modes. @@ -134,6 +149,7 @@ These nodes handle the smaller jobs that show up all over image workflows. - **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. +- **VAE Encode (Options)** and **VAE Decode (Options)** wrap ComfyUI's normal and tiled VAE paths behind one explicit tiling toggle, with spatial and temporal tile controls available when tiling is enabled. - **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. @@ -144,13 +160,13 @@ The provenance nodes trace the graph. They do not guess from tensor values. If a ## Settings -SimpleSyrup adds one ComfyUI setting: +SimpleSyrup adds ComfyUI settings for model visibility and external LLM access: -- **SimpleSyrup: Show downloadable models in loader dropdowns** +- **SimpleSyrup: Show downloadable models in loader dropdowns** controls whether known downloadable SAM, GroundingDINO, ViTMatte, and WD14 models appear before they are installed locally. +- **SimpleSyrup: External LLM endpoint** stores the OpenAI-compatible base URL used by external LLM prompt nodes. +- **SimpleSyrup: External LLM API key** stores the API key for that endpoint in OS credential storage. -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. - -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. +When downloadable models are shown, supported loaders list known model choices even if the files are not installed yet. When they are hidden, those dropdowns only show models SimpleSyrup can verify locally. Anima's automatic Qwen text encoder and VAE resolution is handled by the Anima loader itself. ## License & Acknowledgements From 30381c41f831587e63bb5cfdfb57c114ecc1f9d1 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sat, 11 Jul 2026 16:25:05 -0400 Subject: [PATCH 18/40] feat(masking): expand segmentation tooling and progress --- PLAN.md | 993 ++++++++---------- simple_syrup/masking/mask_components.py | 83 ++ .../nodes/detect_segs_with_ultralytics.py | 22 +- .../nodes/grounding_dino_model_loader.py | 6 + simple_syrup/nodes/prompt_segs_with_sam.py | 22 +- simple_syrup/nodes/sam_model_loader.py | 6 + simple_syrup/nodes_v3/__init__.py | 2 + simple_syrup/nodes_v3/mask_to_segs.py | 243 +++++ simple_syrup/runtime/grounding_dino_loader.py | 94 +- simple_syrup/runtime/progress.py | 75 ++ simple_syrup/runtime/sam_loader.py | 66 +- simple_syrup/runtime/sam_segmenter.py | 45 + simple_syrup/runtime/text_box_detector.py | 45 + simple_syrup/services/mask_to_segs_service.py | 125 +++ simple_syrup/services/segs_output_service.py | 44 +- tests/test_grounding_dino_loader.py | 64 ++ tests/test_mask_to_segs_node.py | 256 +++++ tests/test_mask_to_segs_service.py | 304 ++++++ tests/test_persisted_widget_order_contract.py | 259 +++++ tests/test_phase_progress.py | 84 ++ tests/test_registration.py | 1 + tests/test_sam_loader.py | 64 +- tests/test_segs_output_service.py | 141 +++ 23 files changed, 2406 insertions(+), 638 deletions(-) create mode 100644 simple_syrup/masking/mask_components.py create mode 100644 simple_syrup/nodes_v3/mask_to_segs.py create mode 100644 simple_syrup/services/mask_to_segs_service.py create mode 100644 tests/test_mask_to_segs_node.py create mode 100644 tests/test_mask_to_segs_service.py create mode 100644 tests/test_persisted_widget_order_contract.py create mode 100644 tests/test_phase_progress.py create mode 100644 tests/test_segs_output_service.py diff --git a/PLAN.md b/PLAN.md index 8650296..16d8112 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,648 +1,516 @@ -# Plan: Tag SEGS w/ External LLM +# Plan: Image-Associated Mask to SEGS ## Goal -Add a new Comfy v3-only node named `Tag SEGS w/ External LLM`. +Add a SimpleSyrup node that converts an existing ComfyUI mask into Impact-compatible SEGS while associating every SEG with cropped image data from the source image. -The node will take an image, existing Impact-compatible SEGS, and a CLIP object. It will show each SEG crop to the configured external vision LLM, turn each LLM response into a positive regional prompt, CLIP-encode those prompts in the same order as the SEGS, and return the unchanged SEGS plus an aligned `CONDITIONING_BATCH` output named `positive`. +The node should feel like `Detect SEGS w/ Ultralytics`, but it must take an `IMAGE` and a `MASK` instead of an image and detector model. It should expose the same general region workflow controls where they make sense: size filtering, keep-only limiting, mask dilation, post-dilation, crop factor, sort order, and optional unioning into one SEG. -The intended workflow is the same regional-conditioning role currently served by `Tag SEGS w/ WD14` and `Tile & Tag SEGS`, but with an external vision model instead of WD14. The node should be useful when a vision LLM can produce better region descriptions than WD14 tags. +## Decisions From Maintainer Discussion -## Decisions From Planning +- This should be a first-class SimpleSyrup node, not just documentation for an Impact Pack workflow chain. +- The node should behave like a detector-style SEGS source because downstream users think of this as "pre-detected regions from a mask." +- The node must take both `image` and `mask`. +- The output SEGS must include full image association by storing each SEG's `cropped_image` from the input image. +- The node should support masks with multiple disconnected regions. +- The node should let users choose whether disconnected regions become separate SEGs or one combined SEG. +- The node should include controls similar to `Detect SEGS w/ Ultralytics` and in a similar order. +- The node should not expose detector confidence controls because the input is an existing mask, not detector predictions. +- Segment confidence should be fixed at `1.0`. +- The existing Impact Pack nodes can already do some of this with `MASK to SEGS` plus `Set Default Image for SEGS`, but that is not the desired UX for SimpleSyrup. -- Export only through the Comfy v3 node path. -- Do not add a legacy node class. -- Do not add `NODE_CLASS_MAPPINGS` or `NODE_DISPLAY_NAME_MAPPINGS`. -- Do not add an internal compatibility shim or dual implementation path. -- Make exactly one external LLM call per SEG. -- Preserve SEGS order exactly. -- Return the original SEGS unchanged, converted only to the existing Impact-compatible output shape. -- Output one `CONDITIONING_BATCH` entry per SEG. -- Fail closed when alignment is uncertain. -- Provide a single widget for how SEG crops are presented to the vision model. -- Support LLM response cleanup controls similar to the WD14 tagging nodes. -- Treat the LLM response as prompt text after lightweight tag formatting. Do not add clever semantic parsing in the first implementation. -- Do not provide opinionated default prompt text for `system_prompt` or `user_prompt`; leave both defaults empty. +## Existing Code To Reuse -## Implementation Status +Use these SimpleSyrup modules as the main implementation references: -- [x] Review plan and current code patterns. - - Landing note: existing `Tag SEGS w/ WD14` owns SEGS validation/alignment, `External LLM Prompt` owns provider settings/key flow, and v3 registration is centralized in `simple_syrup/nodes_v3/__init__.py`. -- [x] Add SEG crop image encoder and tests. - - Landing note: `ExternalLLMSegsImageEncoder` now emits PNG data URLs for `transparent mask`, `black mask`, and `full crop`; focused encoder tests pass. -- [x] Add external LLM image-data-url execution path. - - Landing note: `ExternalLLMPromptService.generate_with_image_data_url()` reuses the existing model/settings/key/client flow while accepting the SEG encoder's prebuilt PNG data URL. -- [x] Add service formatting and orchestration tests. - - Landing note: service tests cover per-SEG LLM call order, prompt formatting, exclusions, progress, logging metadata, validation failures, and conditioning count alignment. -- [x] Add `TagSEGSWithExternalLLMService`. - - Landing note: the service owns SEGS validation, SEG image encoding, pre-encoded LLM calls, response formatting, prompt prefixing, CLIP conditioning encoding, and completion logging. -- [x] Add v3-only node schema and execution forwarding. - - Landing note: `TagSEGSWithExternalLLMV3` defines the schema directly and delegates to `TagSEGSWithExternalLLMService`; no file was added under `simple_syrup/nodes/`. `system_prompt` and `user_prompt` default to empty strings. -- [x] Register v3 node and update registration tests. - - Landing note: `SimpleSyrup.TagSEGSWithExternalLLM` is now returned by `get_nodes()` with the maintained base node list. -- [x] Add tooltips and update tooltip coverage. - - Landing note: all new v3 inputs/outputs use dedicated tooltip constants, and focused registration/tooltip tests pass. -- [x] Update this plan with final landing notes. - - Landing note: implementation completed as a v3-only vertical slice with no new `simple_syrup/nodes/` node class and no legacy mapping export changes. -- [x] Run required verification gates. - - Landing note: `ruff format .`, `ruff check .`, `mypy --strict simple_syrup tests`, and `pytest -n auto -q` pass in `..\..\venv`. +- `simple_syrup/nodes/detect_segs_with_ultralytics.py` + - Existing detector-style node UX and control order. + - Current output shape: `RETURN_TYPES = ("SEGS", "MASK")`. + - Current list behavior for batched images: `OUTPUT_IS_LIST = (True, False)`. + - Applies `limit_segs`, `sort_segs`, and then `build_combined_segs_result`. +- `simple_syrup/services/segs_detection_service.py` + - Existing service pattern for constructing `Segment` objects from masks and image crops. + - Uses `crop_region_for_bbox`, `crop_mask`, `crop_image`, and `dilate_mask`. +- `simple_syrup/services/segs_output_service.py` + - `build_combined_segs_result()` already creates a one-SEG union with `cropped_image`. + - `combined_mask_from_segs()` already produces the mask output from SEGS. + - `coerce_cropped_mask()` validates crop-local SEG masks. + - This module should become the shared owner for detector-style SEGS output finalization. +- `simple_syrup/masking/segs_mask_ops.py` + - Reuse image validation, mask crop, image crop, resize, crop factor, and signed dilation behavior. +- `simple_syrup/domain/segs.py` + - Reuse `Segment`, `BoundingBox`, `CropRegion`, `NativeSegs`, `SORT_ORDER_OPTIONS`, `limit_segs`, `sort_segs`, and `to_impact_compatible_segs`. -## New Node Contract +Use these Impact Pack modules only as behavioral references, not as imports: -Node id: +- `E:\ComfyUI\custom_nodes\comfyui-impact-pack\modules\impact\segs_nodes.py` + - `MaskToSEGS` splits or combines mask regions. + - `DefaultImageForSEGS` attaches cropped source image data after mask conversion. + - `SEGSMerge`, `SEGSOrderedFilter`, and `DilateMaskInSEGS` show existing user expectations. +- `E:\ComfyUI\custom_nodes\comfyui-impact-pack\modules\impact\core.py` + - `mask_to_segs()` uses contour detection for disconnected regions. + - `batch_mask_to_segs()` handles batched masks for video-style masks. -```text -SimpleSyrup.TagSEGSWithExternalLLM -``` +Do not import Impact Pack code. SimpleSyrup tests already enforce avoiding external pack imports. -Display name: +## Node Name And Contract -```text -Tag SEGS w/ External LLM -``` +Add a new Comfy v3 node: -Category: - -```text -SimpleSyrup/Detailing -``` - -Inputs: - -```text -image: IMAGE -segs: SEGS -clip: CLIP -model: external LLM model dropdown -system_prompt: STRING -user_prompt: STRING -universal_positive: STRING -seg_image_mode: COMBO -replace_underscore: BOOLEAN -trailing_comma: BOOLEAN -exclude_tags: STRING -max_tokens: INT -reasoning_effort: COMBO -``` +- Node id: `SimpleSyrup.MaskToSEGS` +- Display name: `Mask to SEGS` +- Category: `SimpleSyrup/Detection` +- Search aliases: `mask`, `segs`, `region`, `detect`, `segmentation` +- Description: `Converts an existing mask into image-associated SEGS for detail and regional workflows.` Outputs: -```text -segs: SEGS -positive: CONDITIONING_BATCH -``` +- `segs: SEGS` +- `mask: MASK` -Suggested default input values: +Output behavior should match `Detect SEGS w/ Ultralytics`: -```text -system_prompt: +- `segs` is list-output compatible, one SEGS payload per input image/mask pair. +- `mask` is a standard ComfyUI batched mask tensor with shape `(B, H, W)`. +- The output mask is the union of the retained SEGS after filtering, sorting, and optional combining. -user_prompt: +## Architecture Landing Shape -universal_positive: +Do not add a third copy of the detector-style output pipeline. -seg_image_mode: -transparent mask +The current code has two nodes that inline the same sequence after SEGS extraction: -replace_underscore: -true +1. `simple_syrup/nodes/detect_segs_with_ultralytics.py` +2. `simple_syrup/nodes/prompt_segs_with_sam.py` -trailing_comma: -false - -exclude_tags: - -max_tokens: -1024 - -reasoning_effort: -default -``` - -Search aliases: - -```text -llm -vision -tag -segs -detail -regional -prompt -``` - -## SEG Image Modes - -Add one combo input named `seg_image_mode` with these exact options: - -```text -transparent mask -black mask -full crop -``` - -### `transparent mask` - -Crop the original image to `segment.crop_region`. - -Crop the SEG mask to the same rectangle. Resize or normalize the mask only if needed to match the crop dimensions. - -Encode a PNG data URL with RGBA channels: - -- RGB comes from the crop. -- Alpha comes from the cropped SEG mask. -- Pixels outside the mask are hidden by alpha. - -Use this as the default because it most directly communicates "only this SEG matters" when the provider supports alpha. - -### `black mask` - -Crop the original image to `segment.crop_region`. - -Crop the SEG mask to the same rectangle. Resize or normalize the mask only if needed to match the crop dimensions. - -Encode an RGB PNG data URL: - -- Pixels inside the mask keep the original crop color. -- Pixels outside the mask are set to black. - -This is the compatibility mode for providers that ignore PNG alpha or flatten transparent pixels unpredictably. - -### `full crop` - -Crop the original image to `segment.crop_region`. - -Encode the full RGB crop unchanged. - -Ignore the SEG mask for the image sent to the LLM. This gives the model surrounding context when context improves region tagging. - -## Formatting Rules For LLM Responses - -Add formatting controls: - -```text -replace_underscore: BOOLEAN = true -trailing_comma: BOOLEAN = false -exclude_tags: STRING = "" -``` - -For each LLM response: - -1. Strip leading and trailing whitespace. -2. Reject the response if it is empty after stripping. -3. Split the response by commas into tag-like chunks. -4. Trim whitespace around each chunk. -5. Drop empty chunks. -6. If `replace_underscore` is enabled, replace `_` with spaces inside each chunk. -7. Parse `exclude_tags` as a comma-separated list. -8. Normalize excluded tags using the same trim, empty-drop, and optional underscore replacement policy. -9. Remove exact tag matches after normalization. -10. Rejoin remaining tags with `, `. -11. Reject the prompt if no tags remain after exclusion. -12. If `trailing_comma` is enabled and the prompt does not already end in a comma, append `,`. -13. Prefix with `universal_positive` using `domain.prompt_composition.prefix_prompt`. - -Do not fuzzy-match excluded tags. - -Do not parse JSON, markdown, prose sections, or model-specific response schemas in the first implementation. Prompt discipline should keep responses comma-like. If a model returns prose, the comma splitting still gives deterministic behavior. - -## Architecture - -Follow the existing layer boundaries in `AGENTS.md`. - -### Domain Layer - -Use existing domain objects where possible: - -- `simple_syrup/domain/segs.py` -- `simple_syrup/domain/conditioning_batch.py` -- `simple_syrup/domain/prompt_composition.py` -- `simple_syrup/domain/external_llm.py` - -Add a small domain value object only if it removes duplication or clarifies validation. A likely useful object is a frozen dataclass for formatting controls: +Both nodes currently perform: ```python -@dataclass(frozen=True) -class LLMTagFormattingControls: - replace_underscore: bool - trailing_comma: bool - exclude_tags: str +segs = limit_segs(segs, keep_only, keep_by) +segs = sort_segs(segs, sort_order) +combined = build_combined_segs_result(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) ``` -Place it where ownership is clearest after implementation. If the formatting is used only by this service, keep it service-local. If it becomes shared with future LLM tagging code, place it in a domain module. +The new mask node must not duplicate this sequence inline. Instead, implement a shared output-finalization helper in `simple_syrup/services/segs_output_service.py` and refactor the existing detector nodes to use it. -### Runtime / Adapter Layer +Recommended shape: -Add a crop image encoder near: +- `MaskToSEGSService` + - Owns only one-image, one-mask extraction into separate native SEGS. + - Does not own `keep_only`, `sort_order`, `combine_segs`, Impact-compatible conversion, or output mask construction. +- `segs_output_service` + - Owns the common detector-style post-processing pipeline: + 1. limit SEGS + 2. sort SEGS + 3. build the combined result + 4. choose separate or combined output SEGS + 5. convert output SEGS to Impact-compatible shape + 6. return the paired mask output +- Node classes + - Own Comfy-facing schema, batch iteration, and wiring only. + - Delegate source-specific extraction to a source service. + - Delegate shared final output shaping to `segs_output_service`. -```text -simple_syrup/runtime/external_llm_images.py -``` +This keeps ownership strict: -Recommended additions: +- Mask-derived region extraction belongs to the mask-to-SEGS service. +- Detector model inference belongs to detector services. +- Prompt/SAM detection belongs to the SAM prompt service. +- Sorting, limiting, combining, and output mask generation belong to the shared SEGS output service. +- Domain helpers remain pure SEGS policies and value conversions. -```python -SEG_IMAGE_MODES = ("transparent mask", "black mask", "full crop") +## Input Schema -class ExternalLLMSegsImageEncoder: - """Encode SEG crops for OpenAI-compatible vision payloads.""" +Use this input order: - def encode_segment_as_data_url( - self, - image: torch.Tensor, - segment: Segment, - mode: str, - ) -> str: - """Return one SEG crop as a PNG data URL.""" -``` +1. `image: IMAGE` +2. `mask: MASK` +3. `mask_threshold: FLOAT` +4. `size_threshold: INT` +5. `keep_only: INT` +6. `mask_dilation: INT` +7. `post_dilation: INT` +8. `crop_factor: FLOAT` +9. `sort_order: SORT_ORDER_OPTIONS` +10. `combine_segs: BOOLEAN` +11. `label: STRING` -This adapter owns tensor-to-PIL conversion and PNG/base64 data URL encoding. +Detailed input behavior: -The existing `ExternalLLMImageEncoder.encode_first_image_as_data_url()` sends the first whole image. Do not overload that method with SEG-specific behavior. Keep the SEG crop behavior separate so the existing `External LLM Prompt` node remains simple and unchanged. +- `image` + - Source image used to associate cropped image data with every SEG. + - Must be a ComfyUI `IMAGE` tensor shaped `(B, H, W, C)`. +- `mask` + - Source mask to convert into SEGS. + - Must be a ComfyUI `MASK` tensor shaped `(B, H, W)` or a single mask compatible with the image batch. + - The implementation may support a single mask for a single image first. If supporting image batches, batch count must either match the image batch or be exactly `1` for reuse across all images. +- `mask_threshold` + - Default: `0.5` + - Min: `0.0` + - Max: `1.0` + - Step: `0.01` + - Converts soft masks into active pixels before region extraction. + - Active pixels are `mask >= mask_threshold`. +- `size_threshold` + - Default: `10` + - Min: `1` + - Max: `8192` + - Drops extracted regions whose bounding box is smaller than this many pixels wide or tall. + - Match the wording and role of `Detect SEGS w/ Ultralytics`'s `size_threshold`. +- `keep_only` + - Default: `0` + - Min: `0` + - Max: `4096` + - Keeps only the largest N regions after size filtering. `0` keeps all regions. + - Do not expose a confidence ranking option. There is no detector confidence. +- `mask_dilation` + - Default: `0` + - Min: `-512` + - Max: `512` + - Signed dilation applied to the full input mask before region extraction. + - Positive grows mask regions; negative erodes them. +- `post_dilation` + - Default: `0` + - Min: `-512` + - Max: `512` + - Signed dilation applied to each final crop-local SEG mask after the crop is chosen. + - This mirrors the Ultralytics node's final SEG mask cleanup behavior. +- `crop_factor` + - Default: `3.0` + - Min: `0.0` + - Max: `100.0` + - Step: `0.1` + - `0.0` means use the full image as the crop, matching SimpleSyrup's existing detector convention. + - Values greater than or equal to `1.0` expand the SEG crop around the extracted region's bounding box. + - Values between `0.0` and `1.0` must fail with an actionable `ValueError`. +- `sort_order` + - Use `SORT_ORDER_OPTIONS` from `simple_syrup.domain.segs`. + - Default: `largest to smallest`. + - Sort extracted regions before output and before any combined mask result is built. +- `combine_segs` + - Default: `False` + - `False` returns one SEG per disconnected mask region. + - `True` returns one unioned SEG representing all retained mask pixels. +- `label` + - Default: `mask` + - Label applied to extracted SEGs. + - If `combine_segs` is true, the combined output label should be `combined` unless there is a strong reason to preserve the user label. Match `build_combined_segs_result()` unless intentionally extending it. -Use existing masking helpers where possible: +## Behavior Details -- `validate_single_image` -- `crop_image` -- `crop_mask` -- `resize_mask` +### Region Extraction -If `segment.cropped_mask` is already crop-local, do not assume it is full-image. The service or encoder must normalize the mask robustly: - -- If the mask shape matches the crop height and width, use it directly. -- If the mask shape matches the full source image height and width, crop it by `segment.crop_region`. -- If the mask has a batch dimension, normalize to one HW mask. -- If the mask shape is compatible but not exact, resize with bilinear interpolation and clamp to `0.0..1.0`. -- If the mask cannot be interpreted as HW or BHW, raise a clear `ValueError`. - -Preserve security boundaries: - -- No filesystem writes are needed. -- No workflow-provided code execution. -- No shell invocation. -- No network logic inside the image encoder. - -### Application / Service Layer - -Add a service module: - -```text -simple_syrup/services/tag_segs_with_external_llm_service.py -``` - -Recommended public result: - -```python -@dataclass(frozen=True) -class TagSEGSWithExternalLLMResult: - """Return unchanged SEGS and aligned external-LLM conditioning.""" - - segs: ImpactSegs - positive: ConditioningBatch -``` - -Recommended service: - -```python -class TagSEGSWithExternalLLMService: - """Caption provided SEGS crops with an external LLM and encode conditioning.""" -``` +Add a new service, probably `simple_syrup/services/mask_to_segs_service.py`, with a class such as `MaskToSEGSService`. The service should own: -- Input validation. -- External LLM model resolution. -- External LLM settings and credential checks. -- One provider request per SEG. -- Progress reporting. -- Response formatting. -- Prompt prefixing. -- CLIP conditioning encoding. -- Alignment checks. -- Structured logging. +- Image and mask validation. +- Mask thresholding. +- Signed full-mask dilation. +- Disconnected region extraction. +- Per-region bounding box calculation. +- Size filtering. +- Crop region calculation. +- Crop-local mask creation. +- Optional post-dilation on each crop-local mask. +- Cropped image association. +- Native immutable SEGS output. -Keep external boundaries injectable for tests: +The service must not own: + +- `keep_only` +- final `sort_order` +- `combine_segs` +- Impact-compatible conversion +- final output mask construction + +Those responsibilities belong to the shared SEGS output finalization helper. + +The node should own only Comfy-facing schema, input ordering, batch iteration, and wiring. + +### Connected Components + +Implement disconnected region extraction inside SimpleSyrup. Do not import Impact Pack. + +Prefer a torch-based or standard-library implementation over adding a new required runtime dependency. A simple deterministic flood-fill or connected-components routine is acceptable because masks are 2D binary tensors and the node is not model-bound. + +Connectivity decision: + +- Use 8-connected components unless tests or existing SimpleSyrup behavior strongly point to 4-connected components. +- Document this in the service docstring and tests. +- 8-connected behavior usually matches user expectations for painted masks where diagonal contact should remain one region. + +Extraction algorithm outline: + +1. Convert mask to a CPU `torch.bool` active-pixel tensor after threshold and dilation. +2. If no pixels are active, return empty native SEGS. The shared output finalization helper is responsible for turning that into a zero mask output. +3. Find connected components. +4. For each component, compute bbox from active pixel coordinates. +5. Drop components where bbox width or height is less than `size_threshold`. +6. Create one `Segment` per kept component. + +The service always returns separate native SEGS. `combine_segs` is handled later by the shared output finalization helper so all detector-style SEGS nodes use the same combine behavior. + +### Mask Values + +Use thresholded masks to decide region membership, but preserve useful soft-mask values when forming crop-local masks where possible. + +Recommended behavior: + +- Use the thresholded, dilated mask for topology and bbox extraction. +- Use the original normalized mask after `mask_dilation` as the crop-local mask values. +- Zero out pixels outside the active component for each separate SEG. +- Clamp all final masks to `0.0..1.0`. + +This lets soft masks retain feathered values inside each SEG while still giving deterministic region extraction. + +### Cropped Image Association + +Every returned `Segment` must set: + +- `cropped_image = crop_image(single_image, crop_region).detach().clone()` +- `cropped_mask = crop-local mask tensor` +- `confidence = 1.0` +- `crop_region = CropRegion(...)` +- `bbox = BoundingBox(...)` +- `label = label` +- `control_net_wrapper = None` + +This is the key difference from Impact Pack's `MASK to SEGS`, which creates SEGS without cropped image data and relies on a separate `Set Default Image for SEGS` node. + +### Batch Handling + +Match existing SimpleSyrup detector behavior as closely as possible: + +- Validate the image batch with `validate_image_batch()`. +- Iterate image items with `iter_single_images()`. +- Produce one SEGS payload per image. +- Concatenate mask outputs into one `(B, H, W)` tensor. + +Mask batch rules: + +- If mask batch size equals image batch size, pair by index. +- If mask batch size is `1` and image batch size is greater than `1`, reuse the mask for every image. +- Otherwise raise `ValueError` explaining the mismatch. +- Mask height and width must match the image height and width. Do not silently resize masks for this node unless the maintainer explicitly approves it later. + +## Sorting And Keeping + +Do not expose `keep_by` unless there is a future product decision to add multiple non-confidence policies. + +For `keep_only`, retain the largest regions by crop area before final sorting. Reuse `limit_segs(segs, keep_only, "largest size")` inside the shared output finalization helper for this node. Do not add a new ranking abstraction unless tests show the current domain helper cannot express the behavior clearly. + +Final output order must come from `sort_segs(segs, sort_order)`. + +## Shared Output Finalization + +Add a small result type and helper to `simple_syrup/services/segs_output_service.py`. + +Suggested result type: ```python -class VisionLLMBoundary(Protocol): - def generate( - self, - model: str, - system_prompt: str, - user_prompt: str, - max_tokens: int, - reasoning_effort: str, - image: object | None = None, - ) -> str: - """Return one assistant response.""" +@dataclass(frozen=True) +class FinalizedSegsOutput: + """Return Impact-compatible SEGS and its paired output mask.""" - -class SegmentImageEncodingBoundary(Protocol): - def encode_segment_as_data_url( - self, - image: torch.Tensor, - segment: Segment, - mode: str, - ) -> str: - """Return one SEG crop image data URL.""" - - -class ConditioningEncodingBoundary(Protocol): - def encode_batch(self, clip: Any, chunks: tuple[str, ...]) -> ConditioningBatch: - """Return conditioning entries in prompt order.""" + segs: object + mask: torch.Tensor ``` -The existing `ExternalLLMPromptService.generate()` expects an `image` object and then asks its own image encoder to encode it. For this node, prefer adding a provider-call method that accepts a prebuilt image data URL, or extract shared provider execution into a helper. Do not pass already encoded data URLs through an API that claims to accept Comfy `IMAGE` tensors. - -Recommended clean shape: - -- Keep `ExternalLLMPromptService.generate()` behavior unchanged. -- Add a method to the same service or a new small collaborator that accepts `image_data_url`. -- Reuse the same settings repository, key store, model choice, and provider client logic. -- Avoid duplicating endpoint/API-key validation across services if it can be extracted without creating an unclear abstraction. - -The service execution should look like: - -1. Validate `image` as a single BHWC IMAGE tensor. -2. Coerce `segs` with `coerce_segs`. -3. Validate SEGS header matches the image height and width. -4. Validate every `segment.crop_region` fits inside the image. -5. Reject empty SEGS. -6. Resolve the selected LLM model. -7. Create progress with `len(segments) + 2`. -8. For each segment in input order: - - Encode one SEG crop according to `seg_image_mode`. - - Call the external LLM once with that crop. - - Format the LLM response. - - Prefix with `universal_positive`. - - Append the prompt to an ordered tuple/list. - - Update progress. -9. Encode the prompt tuple using `ComfyConditioningEncoder.encode_batch`. -10. Verify `len(positive.entries) == len(segments)`. -11. Log completion with operation name, segment count, selected model, image mode, and formatting-control presence. -12. Return `to_impact_compatible_segs(native_segs)` and `positive`. - -Suggested operation constant: +Suggested helper: ```python -OPERATION = "Tag SEGS w/ External LLM" +def finalize_detector_segs_output( + image: object, + segs: NativeSegs, + keep_only: int, + keep_by: str, + crop_factor: float, + sort_order: str, + combine_segs: bool, +) -> FinalizedSegsOutput: + """Apply shared detector-style SEGS output policy.""" ``` -### Comfy v3 Node Layer +Behavior: -Add only: +1. Apply `limit_segs(segs, keep_only, keep_by)`. +2. Apply `sort_segs(segs, sort_order)`. +3. Build `combined = build_combined_segs_result(image, segs, crop_factor)`. +4. Use `combined.segs` when `combine_segs` is true; otherwise use the sorted separate SEGS. +5. Convert chosen SEGS with `to_impact_compatible_segs`. +6. Return the converted SEGS and `combined.mask`. -```text -simple_syrup/nodes_v3/tag_segs_with_external_llm.py -``` +For the new mask node, call this helper with `keep_by="largest size"` internally. -Do not add: +Refactor these existing nodes to use the helper as part of this change: -```text -simple_syrup/nodes/tag_segs_with_external_llm.py -``` +- `simple_syrup/nodes/detect_segs_with_ultralytics.py` +- `simple_syrup/nodes/prompt_segs_with_sam.py` -The v3 node should: +Add characterization tests before refactoring or preserve existing tests that already prove: -- Define the schema directly. -- Use `comfy_api.latest`. -- Use `Custom("CONDITIONING_BATCH")` for the positive output. -- Use model choices from the external LLM service, like the current `External LLM Prompt` node does. -- Delegate non-trivial behavior to `TagSEGSWithExternalLLMService`. -- Contain only input declaration and execution forwarding. +- final sorting happens before combined output construction +- keep-only limiting happens before final sorting +- `combine_segs` chooses the combined SEGS while retaining the same output mask +- returned SEGS are Impact-compatible -Register the node in: +The helper should be narrow. Do not move source-specific detection behavior into it. -```text -simple_syrup/nodes_v3/__init__.py -``` +## V3 Registration -The root package export must continue to expose only `comfy_entrypoint`. +Comfy v3 is the only supported export path. + +Preferred implementation: + +- Add a direct v3 node class under `simple_syrup/nodes_v3/mask_to_segs.py`. +- Register it in `simple_syrup/nodes_v3/__init__.py::get_nodes()`. +- Do not add `NODE_CLASS_MAPPINGS` or legacy export mappings. + +If reusing the legacy adapter pattern would materially reduce risk, it is acceptable to add a legacy-style internal node class under `simple_syrup/nodes/` and wrap it with `LegacyNodeV3Adapter`, but the public export must still be v3-only. + +The implementation should look native to the current codebase and should not add compatibility shims. ## Tooltips -Update: +Every visible input and output must have concise user-facing tooltip text. -```text -simple_syrup/nodes/tooltips.py -``` +Required tooltip intent: -Every visible input and output needs a concise user-facing tooltip. +- `image`: source image used for SEG crops. +- `mask`: mask whose active regions become SEGS. +- `mask_threshold`: threshold used to decide active mask pixels. +- `size_threshold`: smallest region width or height to keep, in pixels. +- `keep_only`: maximum number of largest regions to keep; `0` keeps all. +- `mask_dilation`: grow or shrink the source mask before regions are found. +- `post_dilation`: grow or shrink each final SEG mask after cropping. +- `crop_factor`: context around each region; `0` uses the full image. +- `sort_order`: output ordering for separate SEGS. +- `combine_segs`: return one unioned SEG instead of separate regions. +- `label`: label stored on extracted SEGs. +- `segs` output: image-associated SEGS from the mask. +- `mask` output: union of retained SEGS as a ComfyUI mask. -Suggested tooltip intent: +## Tests To Add -- `image`: The source image that the SEGS were detected from. -- `segs`: The regions to describe and align with conditioning. -- `clip`: The CLIP/text encoder used to encode generated regional prompts. -- `model`: The configured external vision model used for each SEG crop. -- `system_prompt`: Instructions that control how the model writes tags. -- `user_prompt`: The per-region request sent with each SEG crop. -- `universal_positive`: Prompt text added before every generated region prompt. -- `seg_image_mode`: How pixels outside the SEG mask are shown to the vision model. -- `replace_underscore`: Converts booru-style underscores into spaces before encoding. -- `trailing_comma`: Adds a final comma to each generated prompt when enabled. -- `exclude_tags`: Comma-separated exact tags to remove from LLM responses. -- `max_tokens`: Maximum response length for each SEG request. -- `reasoning_effort`: Provider-specific reasoning control, when supported. -- `segs` output: The original SEGS, kept in the same order as the conditioning batch. -- `positive` output: One CLIP-encoded positive conditioning entry per SEG. +Add focused tests before or alongside implementation. -Keep wording concise. Do not document removed choices or alternatives the node does not expose. +### Shared Output Finalization Tests -## Tests - -Add or update tests alongside implementation. - -### Image Encoder Tests - -Add: - -```text -tests/test_external_llm_segs_images.py -``` +Add tests for the new helper in `tests/test_segs_output_service.py` or extend the existing SEGS output service coverage currently housed in `tests/test_ultralytics_detection_service.py`. Cover: -- `transparent mask` returns a PNG data URL with alpha. -- Alpha is low/zero outside the SEG mask and high/one inside it. -- `black mask` returns RGB where outside-mask pixels are black. -- `full crop` preserves the crop rectangle without masking. -- Crop dimensions match `segment.crop_region`. -- Crop-local masks are accepted. -- Full-image masks are accepted and cropped. -- Invalid mask rank fails with an actionable `ValueError`. -- Unknown `seg_image_mode` fails with an actionable `ValueError`. +- Applies `limit_segs` before `sort_segs`. +- Builds the combined result from the limited and sorted SEGS. +- Returns separate SEGS when `combine_segs` is false. +- Returns one combined SEG when `combine_segs` is true. +- Always returns the mask from `build_combined_segs_result`. +- Converts output SEGS to Impact-compatible tuple/list shape. +- Supports `keep_by="largest size"` for mask-derived SEGS. -Use small deterministic tensors, such as 4x4 or 6x6 images, so pixel assertions are simple. +After this helper is tested, refactor `Detect SEGS w/ Ultralytics` and `Prompt SEGS w/ SAM` to use it without changing their public behavior. ### Service Tests -Add: - -```text -tests/test_tag_segs_with_external_llm_service.py -``` +Create `tests/test_mask_to_segs_service.py`. Cover: -- Existing SEGS, LLM responses, formatted prompts, and conditioning stay aligned by index. -- The service makes one LLM call per SEG. -- The service sends the selected `seg_image_mode` to the encoder. -- `universal_positive` is prefixed to every generated prompt. -- `replace_underscore=True` turns `blue_hair` into `blue hair`. -- `replace_underscore=False` preserves `blue_hair`. -- `exclude_tags` removes exact normalized tags. -- `trailing_comma=True` adds a comma after formatting. -- Empty SEGS raise `ValueError`. -- Mismatched SEGS header and image dimensions raise `ValueError`. -- Crop regions outside the image raise `ValueError`. -- Empty LLM responses raise `ValueError`. -- Responses that become empty after exclusions raise `ValueError`. -- Conditioning encoder returning the wrong entry count raises `ValueError`. -- Completion logging includes operation, segment count, model, image mode, and universal-positive presence. +- Single rectangular mask creates one SEG. +- `cropped_image` matches the source image crop. +- `cropped_mask` matches the mask crop. +- Two disconnected regions create two separate SEGs. +- Service always returns separate SEGS; combining is covered by shared output finalization tests and node tests. +- Empty mask returns empty native SEGS from the service and a zero mask output through the node/output helper. +- `mask_threshold` controls active pixels. +- `mask_dilation` grows a source mask before region extraction. +- Negative `mask_dilation` erodes a source mask. +- `post_dilation` changes only crop-local final masks. +- `crop_factor` expands crop regions. +- `crop_factor = 0.0` uses the full image. +- `0.0 < crop_factor < 1.0` raises `ValueError`. +- `size_threshold` drops small components. +- Soft mask values are clamped and retained inside component masks. +- Batch mask and image mismatch raises an actionable error if batch handling is in the service. -Use fake boundaries for LLM calls, image encoding, conditioning encoding, and progress. Do not call a real provider in tests. +### Node Contract Tests -### V3 Node Tests - -Add: - -```text -tests/test_tag_segs_with_external_llm_v3_node.py -``` +Create `tests/test_mask_to_segs_node.py` or a v3-specific equivalent. Cover: -- Schema node id is `SimpleSyrup.TagSEGSWithExternalLLM`. -- Display name is `Tag SEGS w/ External LLM`. -- Category is `SimpleSyrup/Detailing`. -- Inputs are exposed in the intended order. -- `seg_image_mode` choices are exactly `transparent mask`, `black mask`, `full crop`. -- `transparent mask` is the default. -- Formatting controls have intended defaults. -- Outputs are `segs` and `positive`. -- Output types are `SEGS` and `CONDITIONING_BATCH`. -- `execute()` forwards all inputs to the service. +- Node id is `SimpleSyrup.MaskToSEGS`. +- Display name is `Mask to SEGS`. +- Category is `SimpleSyrup/Detection`. +- Outputs are `SEGS` and `MASK`. +- The input list order exactly matches the plan. +- Every input has a tooltip. +- Every output has a tooltip. +- No confidence input exists. +- No detector model input exists. +- No `keep_by` widget exists unless the implementation deliberately adds more non-confidence policies and updates this plan. +- Execution returns Impact-compatible SEGS and a `(B, H, W)` mask. +- `combine_segs = false` returns separate SEGs. +- `combine_segs = true` returns one combined SEG. +- `keep_only` keeps the largest mask-derived regions. +- `sort_order` orders separate regions using existing domain policies. +- Batched images return list-output SEGS and batched masks. ### Registration Tests -Update existing registration/v3 export tests. The new node must appear in the v3 entrypoint output from: +Update existing registration tests: -```text -simple_syrup/nodes_v3/__init__.py::get_nodes() -``` +- `SimpleSyrup.MaskToSEGS` appears in `get_nodes()`. +- Adding the node does not remove or rename existing nodes. +- Root `comfy_entrypoint` still exposes v3 nodes only. -Also update tooltip coverage tests so the new node does not create coverage gaps. +### Tooltip Coverage Tests -## Validation And Errors +Update `tests/test_node_tooltips.py` so the new node passes the repository tooltip requirements. -Use explicit, actionable errors. +## Implementation Steps -Required validation failures: - -- `image` is not a torch IMAGE tensor. -- `image` batch size is not 1. -- `segs` is not a valid SEGS payload. -- SEGS header dimensions do not match the image. -- SEGS contains no segments. -- A SEG crop region is outside the image. -- `seg_image_mode` is unknown. -- The external LLM provider is not configured. -- The external LLM API key is missing. -- The external LLM response is empty. -- Formatting removes every generated tag. -- The conditioning encoder returns a count different from the SEG count. - -Do not silently continue after invalid inputs or provider failures. - -## Logging And Progress - -Use `simple_syrup.shared.logging.get_logger`. - -At completion, log an `info` event with: - -```text -operation: tag_segs_with_external_llm -segment_count -external_llm_model -seg_image_mode -universal_positive_present -replace_underscore -trailing_comma -exclude_tags_present -``` - -Provider failures should preserve exception context. Existing external LLM provider errors can propagate if they are already actionable. - -Use Comfy progress in the service: - -- Start with one initial progress update after validation/model resolution. -- Update after each SEG LLM call. -- Update once after conditioning encode. - -## Security And Runtime Constraints - -- Use the ComfyUI virtual environment located at `..\..\venv`. -- Do not create a repository-local `.venv`. -- Do not use global Python for verification. -- Use PowerShell command forms on Windows. -- Do not write temporary crop files to disk. -- Do not log API keys, provider secrets, or full data URLs. -- Do not execute workflow-provided code. -- Keep network calls inside the external LLM client/provider boundary. -- Keep filesystem, subprocess, and network behavior out of node classes and domain logic. -- Keep import-time behavior cheap. The node schema may read cached model names, but must not make provider requests during import/schema declaration. - -## Required Files To Touch - -Expected new files: - -```text -simple_syrup/services/tag_segs_with_external_llm_service.py -simple_syrup/nodes_v3/tag_segs_with_external_llm.py -tests/test_external_llm_segs_images.py -tests/test_tag_segs_with_external_llm_service.py -tests/test_tag_segs_with_external_llm_v3_node.py -``` - -Expected modified files: - -```text -simple_syrup/runtime/external_llm_images.py -simple_syrup/services/external_llm_prompt_service.py -simple_syrup/nodes/tooltips.py -simple_syrup/nodes_v3/__init__.py -tests/test_registration.py -tests/test_node_tooltips.py -``` - -Only modify additional files if implementation proves they are the correct ownership location. - -Do not add a file under: - -```text -simple_syrup/nodes/ -``` - -for this node. - -## Implementation Sequence - -1. Add characterization tests around the existing external LLM prompt service if extraction is needed. -2. Add the SEG crop image encoder and image-mode tests. -3. Add response formatting logic and service tests. -4. Add the application service with fake-boundary tests. -5. Add the v3 node schema and execute forwarding tests. -6. Register the v3 node and update registration tests. -7. Add tooltips and update tooltip coverage tests. -8. Run focused tests while developing. -9. Run full verification gates before completion. +- [x] Add or confirm characterization tests for the existing Ultralytics and SAM detector-style output behavior. + - Landing note: Existing node tests already covered separate/combined outputs, keep-only limiting, final sorting, combined-builder input order, and batch handling for both detector nodes. +- [x] Add shared output finalization tests. + - Landing note: `tests/test_segs_output_service.py` now covers limit-before-sort, combined output selection, returned mask preservation, Impact-compatible conversion, and `keep_by="largest size"` for mask-derived SEGS. +- [x] Add the shared output finalization helper in `segs_output_service.py`. + - Landing note: `FinalizedSegsOutput` and `finalize_detector_segs_output()` now own detector-style limit/sort/combine/conversion/mask finalization. +- [x] Refactor `Detect SEGS w/ Ultralytics` and `Prompt SEGS w/ SAM` to use the helper without behavior changes. + - Landing note: Both nodes still own source-specific extraction and batch wiring, but delegate shared output shaping to `finalize_detector_segs_output()` with their injectable combined builders. +- [x] Add the connected-component helper and mask-to-SEGS service. + - Landing note: `mask_components.py` implements deterministic 8-connected component extraction, and `MaskToSEGSService` now converts one image plus one mask into separate native SEGS with cropped image association. +- [x] Add direct v3 node schema and execution wrapper. + - Landing note: `MaskToSEGSV3` defines the Comfy v3 schema directly, owns image/mask batch pairing, and delegates extraction plus shared output finalization. +- [x] Register the node in `simple_syrup/nodes_v3/__init__.py`. + - Landing note: `SimpleSyrup.MaskToSEGS` is included in the base v3 node list. +- [x] Add or update registration and tooltip tests. + - Landing note: Registration expectations include `SimpleSyrup.MaskToSEGS`; the schema-driven tooltip coverage includes every new input and output. +- [x] Run focused tests for shared output finalization, existing detector nodes, the new service, the new node, registration, and tooltips. + - Landing note: Focused plan checks pass: `51 passed`. +- [x] Run full Python gates. + - Landing note: `ruff format .`, `ruff check .`, `mypy --strict simple_syrup tests`, and full `pytest -n auto -q` pass. Full test result: `969 passed`. +- [x] Do not touch frontend code unless the Comfy v3 UI requires it. + - Landing note: No frontend source or generated browser artifact was changed. ## Verification Commands -Run all commands from the repository root with PowerShell syntax. +Run all commands from repository root with the ComfyUI virtual environment two directories above this repo. -Required gates: +Focused checks during development: + +```powershell +..\..\venv\Scripts\python.exe -m pytest -n auto -q tests\test_detect_segs_with_ultralytics_node.py tests\test_prompt_segs_with_sam_node.py +..\..\venv\Scripts\python.exe -m pytest -n auto -q tests\test_segs_output_service.py +..\..\venv\Scripts\python.exe -m pytest -n auto -q tests\test_mask_to_segs_service.py tests\test_mask_to_segs_node.py +..\..\venv\Scripts\python.exe -m pytest -n auto -q tests\test_registration.py tests\test_node_tooltips.py +``` + +Required final gates: ```powershell ..\..\venv\Scripts\ruff.exe format . @@ -651,20 +519,25 @@ Required gates: ..\..\venv\Scripts\python.exe -m pytest -n auto -q ``` -Do not substitute global tools. +If frontend code is touched, also run: -If a required tool is missing from `..\..\venv`, install or update development dependencies in that environment before verification. Do not create a local `.venv`. +```powershell +npm ci +npm run lint:web +npm run typecheck:web +npm run test:web +npm run build:web +``` -## Definition Of Done +## Acceptance Criteria -- The node is exported only through Comfy v3. -- `SimpleSyrup.TagSEGSWithExternalLLM` appears in `get_nodes()`. -- The node returns unchanged SEGS plus one positive conditioning entry per SEG. -- One external LLM call is made per SEG. -- `transparent mask`, `black mask`, and `full crop` are implemented and tested. -- Formatting controls are implemented and tested. -- All validation failures are explicit and actionable. -- Tooltips cover every input and output. -- Tests cover service behavior, image encoding, v3 schema, execution forwarding, registration, and tooltip coverage. -- No legacy node class or legacy mapping export is introduced. -- Required ruff, mypy, and pytest gates pass in `..\..\venv`. +- A user can plug in an image and a mask and get ready-to-use image-associated SEGS. +- A mask with two disconnected regions can produce either two SEGs or one combined SEG. +- The node has detector-style controls that feel aligned with `Detect SEGS w/ Ultralytics`. +- The node exposes no detector confidence controls. +- Each SEG has `cropped_image`, `cropped_mask`, `crop_region`, `bbox`, `label`, and `confidence = 1.0`. +- The returned SEGS are Impact-compatible. +- The returned mask is the union of retained output SEGS. +- The implementation does not import Impact Pack. +- The implementation is covered by behavior tests, node contract tests, registration tests, and tooltip tests. +- Full Python verification gates pass. diff --git a/simple_syrup/masking/mask_components.py b/simple_syrup/masking/mask_components.py new file mode 100644 index 0000000..0109e4c --- /dev/null +++ b/simple_syrup/masking/mask_components.py @@ -0,0 +1,83 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Connected-component helpers for binary mask regions.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import torch + +from ..domain.segs import BoundingBox + + +@dataclass(frozen=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 8-connected components from an HW active-pixel mask.""" + + if active_mask.ndim != 2: + raise ValueError("active_mask must be an HW tensor.") + + active = active_mask.detach().to(device="cpu", dtype=torch.bool) + height = int(active.shape[0]) + width = int(active.shape[1]) + visited = torch.zeros((height, width), dtype=torch.bool) + components: list[MaskComponent] = [] + + for top in range(height): + for left in range(width): + if visited[top, left].item() or not active[top, left].item(): + continue + components.append(_trace_component(active, visited, left, top)) + + return tuple(components) + + +def _trace_component( + active: torch.Tensor, + visited: torch.Tensor, + start_left: int, + start_top: int, +) -> MaskComponent: + """Trace one 8-connected component from its first active pixel.""" + + height = int(active.shape[0]) + width = int(active.shape[1]) + queue: list[tuple[int, int]] = [(start_top, start_left)] + visited[start_top, start_left] = True + pixels: list[tuple[int, int]] = [] + index = 0 + + while index < len(queue): + top, left = queue[index] + index += 1 + pixels.append((top, left)) + for neighbor_top in range(max(0, top - 1), min(height, top + 2)): + for neighbor_left in range(max(0, left - 1), min(width, left + 2)): + if visited[neighbor_top, neighbor_left].item(): + continue + visited[neighbor_top, neighbor_left] = True + if active[neighbor_top, neighbor_left].item(): + queue.append((neighbor_top, neighbor_left)) + + top_values = [top for top, _left in pixels] + left_values = [left for _top, left in pixels] + bbox = BoundingBox( + min(left_values), + min(top_values), + max(left_values) + 1, + max(top_values) + 1, + ) + component_mask = torch.zeros((height, width), dtype=torch.bool) + for top, left in pixels: + component_mask[top, left] = True + return MaskComponent(bbox=bbox, mask=component_mask) diff --git a/simple_syrup/nodes/detect_segs_with_ultralytics.py b/simple_syrup/nodes/detect_segs_with_ultralytics.py index 065f3ce..188ce33 100644 --- a/simple_syrup/nodes/detect_segs_with_ultralytics.py +++ b/simple_syrup/nodes/detect_segs_with_ultralytics.py @@ -15,9 +15,6 @@ 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 @@ -27,6 +24,7 @@ from ..services.segs_detection_service import ( 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) diff --git a/simple_syrup/nodes/grounding_dino_model_loader.py b/simple_syrup/nodes/grounding_dino_model_loader.py index 1c7615f..9d97227 100644 --- a/simple_syrup/nodes/grounding_dino_model_loader.py +++ b/simple_syrup/nodes/grounding_dino_model_loader.py @@ -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, + ), ), ) diff --git a/simple_syrup/nodes/prompt_segs_with_sam.py b/simple_syrup/nodes/prompt_segs_with_sam.py index 0865ef1..d009370 100644 --- a/simple_syrup/nodes/prompt_segs_with_sam.py +++ b/simple_syrup/nodes/prompt_segs_with_sam.py @@ -15,9 +15,6 @@ from ..domain.segs import ( KEEP_BY_OPTIONS, SORT_ORDER_OPTIONS, NativeSegs, - limit_segs, - sort_segs, - to_impact_compatible_segs, ) from ..masking.mask_ops import DETAIL_METHODS from ..masking.prompt_segs_with_sam_service import PromptSEGSWithSAMService @@ -25,6 +22,7 @@ from ..masking.segs_mask_ops import iter_single_images, validate_image_batch from ..services.segs_output_service import ( CombinedSegsResult, build_combined_segs_result, + finalize_detector_segs_output, ) @@ -360,11 +358,17 @@ class PromptSEGSWithSAM: execution_device=execution_device, crop_factor=crop_factor, ) - 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) diff --git a/simple_syrup/nodes/sam_model_loader.py b/simple_syrup/nodes/sam_model_loader.py index af38fbc..8e3958a 100644 --- a/simple_syrup/nodes/sam_model_loader.py +++ b/simple_syrup/nodes/sam_model_loader.py @@ -10,6 +10,7 @@ from typing import Any from ..runtime.model_choices import ModelChoiceService, default_choice from ..runtime.model_downloads import ComfyProgressReporter +from ..runtime.progress import create_comfy_phase_progress from ..runtime.sam_loader import SAMLoaderService from . import tooltips @@ -56,5 +57,10 @@ class SAMModelLoader: sam_model=sam_model, auto_download=True, progress=ComfyProgressReporter(), + phase_progress=create_comfy_phase_progress( + operation="sam_model_load", + subject=sam_model, + total_phases=4, + ), ), ) diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index 10b1de1..940afed 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -41,6 +41,7 @@ def get_nodes() -> list[type[object]]: UpscaleLatentFromImageV3, ViTMatteModelLoaderV3, ) + from .mask_to_segs import MaskToSEGSV3 from .scale_factor import ScaleFactorV3 from .simple_load_checkpoint import SimpleLoadCheckpointV3 from .tag_segs_with_external_llm import TagSEGSWithExternalLLMV3 @@ -68,6 +69,7 @@ def get_nodes() -> list[type[object]]: LatentDiagnosticsV3, LayerStyleSAMModelsAdapterV3, LoadUltralyticsModelV3, + MaskToSEGSV3, PromptEncodeStyleAndNormalizationV3, PromptEncodeStyleV3, PromptSEGSWithSAMV3, diff --git a/simple_syrup/nodes_v3/mask_to_segs.py b/simple_syrup/nodes_v3/mask_to_segs.py new file mode 100644 index 0000000..45d80e6 --- /dev/null +++ b/simple_syrup/nodes_v3/mask_to_segs.py @@ -0,0 +1,243 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node for converting masks into image-associated SEGS.""" + +from __future__ import annotations + +from collections.abc import Callable +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +import torch + +from ..domain.segs import SORT_ORDER_OPTIONS, NativeSegs +from ..masking.segs_mask_ops import iter_single_images, validate_image_batch +from ..services.mask_to_segs_service import MaskToSEGSService +from ..services.segs_output_service import ( + CombinedSegsResult, + build_combined_segs_result, + finalize_detector_segs_output, +) + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class MaskToSEGSV3(_ComfyNodeBase): + """Expose image-associated mask-to-SEGS conversion through Comfy v3.""" + + service_class: ClassVar[type[MaskToSEGSService]] = MaskToSEGSService + combined_builder: ClassVar[ + Callable[[object, NativeSegs, float], CombinedSegsResult] + ] = build_combined_segs_result + + @classmethod + def define_schema(cls) -> Any: + """Declare the Mask to SEGS v3 schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.MaskToSEGS", + display_name="Mask to SEGS", + category="SimpleSyrup/Detection", + description=( + "Converts an existing mask into image-associated SEGS for detail " + "and regional workflows." + ), + search_aliases=["mask", "segs", "region", "detect", "segmentation"], + inputs=[ + _comfy_io.Image.Input( + "image", + tooltip=( + "Source image used to create cropped image data for each SEG." + ), + ), + _comfy_io.Mask.Input( + "mask", + tooltip="Mask whose active regions become SEGS.", + ), + _comfy_io.Float.Input( + "mask_threshold", + default=0.5, + min=0.0, + max=1.0, + step=0.01, + tooltip="Mask value required for a pixel to count as active.", + ), + _comfy_io.Int.Input( + "size_threshold", + default=10, + min=1, + max=8192, + step=1, + tooltip=( + "Discard regions smaller than this many pixels wide or tall." + ), + ), + _comfy_io.Int.Input( + "keep_only", + default=0, + min=0, + max=4096, + step=1, + tooltip="Keep only this many largest regions. Use 0 to keep all.", + ), + _comfy_io.Int.Input( + "mask_dilation", + default=0, + min=-512, + max=512, + step=1, + tooltip=( + "Grow or shrink the source mask in pixels before regions " + "are found." + ), + ), + _comfy_io.Int.Input( + "post_dilation", + default=0, + min=-512, + max=512, + step=1, + tooltip=( + "Grow or shrink each final cropped SEG mask in pixels " + "after cropping." + ), + ), + _comfy_io.Float.Input( + "crop_factor", + default=3.0, + min=0.0, + max=100.0, + step=0.1, + tooltip=( + "Context around each region. Use 0 for the full image; " + "higher values make larger crops." + ), + ), + _comfy_io.Combo.Input( + "sort_order", + options=list(SORT_ORDER_OPTIONS), + default=SORT_ORDER_OPTIONS[0], + tooltip="Order separate SEGS before output and combining.", + ), + _comfy_io.Boolean.Input( + "combine_segs", + default=False, + label_on="combined", + label_off="separate", + tooltip="Return one unioned SEG instead of separate mask regions.", + ), + _comfy_io.String.Input( + "label", + multiline=False, + default="mask", + tooltip="Label stored on each extracted SEG.", + ), + ], + outputs=[ + _comfy_io.SEGS.Output( + "segs", + tooltip="Image-associated SEGS created from the mask.", + is_output_list=True, + ), + _comfy_io.Mask.Output( + "mask", + tooltip="Union of retained SEGS as a standard ComfyUI mask.", + ), + ], + ) + + @classmethod + def execute( + cls, + image: object, + mask: object, + mask_threshold: float, + size_threshold: int, + keep_only: int, + mask_dilation: int, + post_dilation: int, + crop_factor: float, + sort_order: str, + combine_segs: bool, + label: str, + ) -> tuple[object, torch.Tensor]: + """Convert a mask batch into SEGS outputs and a combined mask batch.""" + + image_batch = validate_image_batch(image, "Mask to SEGS") + mask_batch = _validate_mask_batch(mask, image_batch) + service = cls.service_class() + + segs_outputs: list[object] = [] + mask_outputs: list[torch.Tensor] = [] + for index, single_image in enumerate(iter_single_images(image_batch)): + mask_index = index if int(mask_batch.shape[0]) > 1 else 0 + segs = service.build( + image=single_image, + mask=mask_batch[mask_index : mask_index + 1], + mask_threshold=mask_threshold, + size_threshold=size_threshold, + mask_dilation=mask_dilation, + post_dilation=post_dilation, + crop_factor=crop_factor, + label=label, + ) + finalized = finalize_detector_segs_output( + image=single_image, + segs=segs, + keep_only=keep_only, + keep_by="largest size", + crop_factor=crop_factor, + sort_order=sort_order, + combine_segs=combine_segs, + combined_builder=cls.combined_builder, + ) + segs_outputs.append(finalized.segs) + mask_outputs.append(finalized.mask) + + return segs_outputs, torch.cat(mask_outputs, dim=0) + + +def _validate_mask_batch(mask: object, image_batch: torch.Tensor) -> torch.Tensor: + """Return a BHW mask batch compatible with an image batch.""" + + if not isinstance(mask, torch.Tensor): + raise TypeError("Mask to SEGS requires a torch MASK tensor.") + working = mask.float() + if working.ndim == 2: + working = working.unsqueeze(0) + if working.ndim != 3: + raise ValueError("Mask to SEGS requires an HW or BHW mask tensor.") + + image_batch_size = int(image_batch.shape[0]) + mask_batch_size = int(working.shape[0]) + if mask_batch_size not in (1, image_batch_size): + raise ValueError( + "Mask to SEGS requires mask batch size to be 1 or match image batch " + f"size; mask batch is {mask_batch_size}, image batch is " + f"{image_batch_size}." + ) + + image_height = int(image_batch.shape[1]) + image_width = int(image_batch.shape[2]) + mask_height = int(working.shape[1]) + mask_width = int(working.shape[2]) + if mask_height != image_height or mask_width != image_width: + raise ValueError( + "Mask to SEGS requires mask dimensions to match image dimensions; " + f"mask is {mask_height}x{mask_width}, image is " + f"{image_height}x{image_width}." + ) + return working.clamp(0.0, 1.0) diff --git a/simple_syrup/runtime/grounding_dino_loader.py b/simple_syrup/runtime/grounding_dino_loader.py index 4f6bbd5..3c30cfb 100644 --- a/simple_syrup/runtime/grounding_dino_loader.py +++ b/simple_syrup/runtime/grounding_dino_loader.py @@ -27,6 +27,7 @@ from .model_folders import ( resolve_model_file, ) from .model_instance_cache import ModelInstanceCache +from .progress import NullPhaseProgressReporter, PhaseProgressReporter LOGGER = get_logger(__name__) GROUNDING_DINO_RUNTIME_PACKAGE = "simple_syrup.third_party.groundingdino_runtime" @@ -93,47 +94,65 @@ class GroundingDINOLoaderService: text_encoder: str, auto_download: bool, progress: ProgressReporter | None = None, + phase_progress: PhaseProgressReporter | None = None, ) -> LoadedGroundingDINOModel: """Load GroundingDINO and return a `GROUNDING_DINO_MODEL` object.""" - register_required_model_folders(self._folder_paths_module) - entry = get_grounding_dino_entry(grounding_dino_model) - artifact_paths = self._resolve_artifacts(entry, auto_download, progress) - text_encoder_resolution = self.resolve_text_encoder( - text_encoder, - auto_download, - progress, - ) - config_path = _artifact_path_with_suffix(artifact_paths, ".py") - checkpoint_path = _artifact_path_with_suffix(artifact_paths, ".pth") - key = GroundingDINOModelCacheKey( - model_id=entry.entry_id, - config_path=config_path.resolve(), - checkpoint_path=checkpoint_path.resolve(), - text_encoder_path=text_encoder_resolution.path.resolve(), - ) - already_loaded = key in self._cache.entries - loaded = self._cache.get_or_load( - key, - lambda: self._load_uncached_model( - entry, - config_path, - checkpoint_path, - text_encoder_resolution, - ), - ) - if already_loaded: - LOGGER.info( - "GroundingDINO model loaded from process cache", + reporter = phase_progress or NullPhaseProgressReporter() + reporter.advance("resolving_artifacts") + try: + register_required_model_folders(self._folder_paths_module) + entry = get_grounding_dino_entry(grounding_dino_model) + artifact_paths = self._resolve_artifacts(entry, auto_download, progress) + text_encoder_resolution = self.resolve_text_encoder( + text_encoder, + auto_download, + progress, + ) + config_path = _artifact_path_with_suffix(artifact_paths, ".py") + checkpoint_path = _artifact_path_with_suffix(artifact_paths, ".pth") + key = GroundingDINOModelCacheKey( + model_id=entry.entry_id, + config_path=config_path.resolve(), + checkpoint_path=checkpoint_path.resolve(), + text_encoder_path=text_encoder_resolution.path.resolve(), + ) + already_loaded = key in self._cache.entries + if already_loaded: + reporter.advance("cache_hit") + loaded = self._cache.get_or_load( + key, + lambda: self._load_uncached_model( + entry, + config_path, + checkpoint_path, + text_encoder_resolution, + reporter, + ), + ) + if already_loaded: + LOGGER.info( + "GroundingDINO model loaded from process cache", + extra={ + "operation": "grounding_dino_loader", + "model": entry.entry_id, + "config_path": str(config_path), + "checkpoint_path": str(checkpoint_path), + "text_encoder_path": str(text_encoder_resolution.path), + }, + ) + reporter.advance("completed") + return loaded + except Exception: + reporter.advance("failed") + LOGGER.exception( + "GroundingDINO model load failed", extra={ "operation": "grounding_dino_loader", - "model": entry.entry_id, - "config_path": str(config_path), - "checkpoint_path": str(checkpoint_path), - "text_encoder_path": str(text_encoder_resolution.path), + "model": grounding_dino_model, }, ) - return loaded + raise def _load_uncached_model( self, @@ -141,6 +160,7 @@ class GroundingDINOLoaderService: config_path: Path, checkpoint_path: Path, text_encoder_resolution: TextEncoderResolution, + phase_progress: PhaseProgressReporter, ) -> LoadedGroundingDINOModel: """Load and wrap GroundingDINO after resolution and cache lookup.""" @@ -149,7 +169,9 @@ class GroundingDINOLoaderService: config_path, checkpoint_path, text_encoder_resolution.path, + phase_progress, ) + phase_progress.advance("registering_device_management") managed_model = self._device_manager.manage( model, model_id=entry.entry_id, @@ -291,6 +313,7 @@ class GroundingDINOLoaderService: config_path: Path, checkpoint_path: Path, text_encoder_path: Path, + phase_progress: PhaseProgressReporter, ) -> object: """Load GroundingDINO from a known config/checkpoint pair.""" @@ -313,13 +336,16 @@ class GroundingDINOLoaderService: f"Import failed: {error}." ) from error + phase_progress.advance("constructing_model") args = slconfig_module.SLConfig.fromfile(str(config_path)) if getattr(args, "text_encoder_type", "") == "bert-base-uncased": args.text_encoder_type = str(text_encoder_path) model = models_module.build_model(args) + phase_progress.advance("loading_checkpoint") checkpoint = torch.load(str(checkpoint_path), map_location="cpu") clean_state_dict = utils_module.clean_state_dict + phase_progress.advance("loading_state_dict") model.load_state_dict(clean_state_dict(checkpoint["model"]), strict=False) model.eval() model.model_name = entry.entry_id diff --git a/simple_syrup/runtime/progress.py b/simple_syrup/runtime/progress.py index fc39a74..6006123 100644 --- a/simple_syrup/runtime/progress.py +++ b/simple_syrup/runtime/progress.py @@ -6,9 +6,14 @@ from __future__ import annotations +from dataclasses import dataclass from importlib import import_module from typing import Protocol, cast +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + class ProgressReporter(Protocol): """Progress sink compatible with ComfyUI progress bars.""" @@ -24,6 +29,61 @@ class NullProgressReporter: """Ignore progress updates.""" +class PhaseProgressReporter(Protocol): + """Report named phases for a bounded long-running operation.""" + + def advance(self, phase: str) -> None: + """Report that the operation entered ``phase``.""" + + +class NullPhaseProgressReporter: + """Ignore named phase updates outside a Comfy execution context.""" + + def advance(self, phase: str) -> None: + """Ignore one phase update.""" + + +@dataclass +class ComfyPhaseProgressReporter: + """Publish truthful phase boundaries through standard Comfy progress events.""" + + operation: str + subject: str + total_phases: int + + def __post_init__(self) -> None: + """Create a lazily imported Comfy progress bar and initialize state.""" + + if self.total_phases <= 0: + raise ValueError("total_phases must be positive.") + comfy_utils = import_module("comfy.utils") + self._progress_bar = comfy_utils.ProgressBar(self.total_phases) + self._completed_phases = 0 + + def advance(self, phase: str) -> None: + """Publish one phase transition without inventing intra-phase percentages.""" + + self._completed_phases = ( + self.total_phases + if phase == "completed" + else min(self.total_phases - 1, self._completed_phases + 1) + ) + self._progress_bar.update_absolute( + self._completed_phases, + self.total_phases, + ) + LOGGER.info( + "Long operation entered phase", + extra={ + "operation": self.operation, + "subject": self.subject, + "phase": phase, + "completed_phases": self._completed_phases, + "total_phases": self.total_phases, + }, + ) + + def create_comfy_progress(total: int) -> ProgressReporter: """Return a ComfyUI progress bar for total units of work.""" @@ -31,3 +91,18 @@ def create_comfy_progress(total: int) -> ProgressReporter: return NullProgressReporter() comfy_utils = import_module("comfy.utils") return cast(ProgressReporter, comfy_utils.ProgressBar(total)) + + +def create_comfy_phase_progress( + *, + operation: str, + subject: str, + total_phases: int, +) -> PhaseProgressReporter: + """Return a standard Comfy progress publisher for named phases.""" + + return ComfyPhaseProgressReporter( + operation=operation, + subject=subject, + total_phases=total_phases, + ) diff --git a/simple_syrup/runtime/sam_loader.py b/simple_syrup/runtime/sam_loader.py index 23ce776..06b78ea 100644 --- a/simple_syrup/runtime/sam_loader.py +++ b/simple_syrup/runtime/sam_loader.py @@ -24,6 +24,7 @@ from .model_folders import ( resolve_model_file, ) from .model_instance_cache import ModelInstanceCache +from .progress import NullPhaseProgressReporter, PhaseProgressReporter LOGGER = get_logger(__name__) SAM_HQ_RUNTIME_PACKAGE = "simple_syrup.third_party.sam_hq_runtime" @@ -65,43 +66,60 @@ class SAMLoaderService: sam_model: str, auto_download: bool, progress: ProgressReporter | None = None, + phase_progress: PhaseProgressReporter | None = None, ) -> LoadedSAMModel: """Load a known SAM model and return a `SAM_MODEL`-compatible object.""" - register_required_model_folders(self._folder_paths_module) - entry = get_sam_entry(sam_model) - artifact_paths = self._resolve_artifacts(entry, auto_download, progress) - checkpoint_path = artifact_paths[0] - key = SAMModelCacheKey( - model_id=entry.entry_id, - model_type=entry.model_type, - checkpoint_path=checkpoint_path.resolve(), - ) - already_loaded = key in self._cache.entries - loaded = self._cache.get_or_load( - key, - lambda: self._load_uncached_model(entry, checkpoint_path), - ) - if already_loaded: - LOGGER.info( - "SAM model loaded from process cache", - extra={ - "operation": "sam_loader", - "model": entry.entry_id, - "model_type": entry.model_type, - "checkpoint_path": str(checkpoint_path), - }, + reporter = phase_progress or NullPhaseProgressReporter() + reporter.advance("resolving_artifacts") + try: + register_required_model_folders(self._folder_paths_module) + entry = get_sam_entry(sam_model) + artifact_paths = self._resolve_artifacts(entry, auto_download, progress) + checkpoint_path = artifact_paths[0] + key = SAMModelCacheKey( + model_id=entry.entry_id, + model_type=entry.model_type, + checkpoint_path=checkpoint_path.resolve(), ) - return loaded + already_loaded = key in self._cache.entries + if already_loaded: + reporter.advance("cache_hit") + loaded = self._cache.get_or_load( + key, + lambda: self._load_uncached_model(entry, checkpoint_path, reporter), + ) + if already_loaded: + LOGGER.info( + "SAM model loaded from process cache", + extra={ + "operation": "sam_loader", + "model": entry.entry_id, + "model_type": entry.model_type, + "checkpoint_path": str(checkpoint_path), + }, + ) + reporter.advance("completed") + return loaded + except Exception: + reporter.advance("failed") + LOGGER.exception( + "SAM model load failed", + extra={"operation": "sam_loader", "model": sam_model}, + ) + raise def _load_uncached_model( self, entry: ModelEntry, checkpoint_path: Path, + phase_progress: PhaseProgressReporter, ) -> LoadedSAMModel: """Load and wrap a SAM model after artifact resolution and cache lookup.""" + phase_progress.advance("loading_checkpoint") model = self._load_segment_anything_model(entry, checkpoint_path) + phase_progress.advance("registering_device_management") managed_model = self._device_manager.manage( model, model_id=entry.entry_id, diff --git a/simple_syrup/runtime/sam_segmenter.py b/simple_syrup/runtime/sam_segmenter.py index 7486a03..2e90281 100644 --- a/simple_syrup/runtime/sam_segmenter.py +++ b/simple_syrup/runtime/sam_segmenter.py @@ -15,6 +15,7 @@ from numpy.typing import NDArray from .loaded_models import LoadedSAMModel, unwrap_sam_model from .model_device_manager import TorchModelDeviceManager, external_model_inference +from .progress import create_comfy_phase_progress from .sam_loader import SAM_HQ_RUNTIME_PACKAGE @@ -48,6 +49,37 @@ class SAMModelSegmenter: if boxes.numel() == 0: return () + progress = create_comfy_phase_progress( + operation="sam_inference", + subject=_sam_subject(sam_model), + total_phases=3, + ) + progress.advance("preparing_device") + try: + progress.advance("running_inference") + masks = self._segment_boxes( + sam_model, + image, + boxes, + threshold, + execution_device, + ) + except Exception: + progress.advance("failed") + raise + progress.advance("completed") + return masks + + def _segment_boxes( + self, + sam_model: object, + image: torch.Tensor, + boxes: torch.Tensor, + threshold: float, + execution_device: str, + ) -> tuple[torch.Tensor, ...]: + """Run SAM adaptation after progress ownership is established.""" + model = unwrap_sam_model(sam_model) image_array = _tensor_to_rgb_array(image) wrapper = getattr(model, "sam_wrapper", None) @@ -85,6 +117,19 @@ class SAMModelSegmenter: ) +def _sam_subject(model: object) -> str: + """Return non-sensitive model identity for progress diagnostics.""" + + if isinstance(model, LoadedSAMModel): + return model.model_id + model_name = getattr(model, "model_name", None) + return ( + model_name + if isinstance(model_name, str) and model_name + else type(model).__name__ + ) + + def _predict_with_wrapper( wrapper: object, image_array: NDArray[np.uint8], diff --git a/simple_syrup/runtime/text_box_detector.py b/simple_syrup/runtime/text_box_detector.py index b8bd421..fc434ed 100644 --- a/simple_syrup/runtime/text_box_detector.py +++ b/simple_syrup/runtime/text_box_detector.py @@ -23,6 +23,7 @@ from .model_device_manager import ( TorchModelDeviceManager, external_model_inference, ) +from .progress import create_comfy_phase_progress @dataclass(frozen=True) @@ -60,6 +61,37 @@ class GroundingDINOTextBoxDetector: ) -> tuple[TextBoxDetection, ...]: """Return filtered prompt detections in pixel coordinates.""" + progress = create_comfy_phase_progress( + operation="grounding_dino_inference", + subject=_grounding_dino_subject(grounding_dino_model), + total_phases=3, + ) + progress.advance("preparing_device") + try: + progress.advance("running_inference") + detections = self._detect( + grounding_dino_model, + image, + prompt, + threshold, + execution_device, + ) + except Exception: + progress.advance("failed") + raise + progress.advance("completed") + return detections + + def _detect( + self, + grounding_dino_model: object, + image: torch.Tensor, + prompt: str, + threshold: float, + execution_device: str, + ) -> tuple[TextBoxDetection, ...]: + """Run GroundingDINO adaptation after progress ownership is established.""" + if ( isinstance(grounding_dino_model, LoadedGroundingDINOModel) and grounding_dino_model.managed_model is not None @@ -109,6 +141,19 @@ class GroundingDINOTextBoxDetector: ) +def _grounding_dino_subject(model: object) -> str: + """Return non-sensitive model identity for progress diagnostics.""" + + if isinstance(model, LoadedGroundingDINOModel): + return model.model_id + model_name = getattr(model, "model_name", None) + return ( + model_name + if isinstance(model_name, str) and model_name + else type(model).__name__ + ) + + def _detect_with_raw_grounding_dino( model: Any, image: torch.Tensor, diff --git a/simple_syrup/services/mask_to_segs_service.py b/simple_syrup/services/mask_to_segs_service.py new file mode 100644 index 0000000..cda0e4e --- /dev/null +++ b/simple_syrup/services/mask_to_segs_service.py @@ -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 + +"""Service for converting existing masks into image-associated native SEGS.""" + +from __future__ import annotations + +import torch + +from ..domain.segs import NativeSegs, Segment +from ..masking.mask_components import connected_mask_components +from ..masking.segs_mask_ops import ( + crop_image, + crop_mask, + crop_region_for_bbox, + dilate_mask, + validate_single_image, +) +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + + +class MaskToSEGSService: + """Build separate native SEGS from one image and one existing mask.""" + + def build( + self, + image: object, + mask: object, + mask_threshold: float, + size_threshold: int, + mask_dilation: int, + post_dilation: int, + crop_factor: float, + label: str, + ) -> NativeSegs: + """Convert active mask components into image-associated SEGS.""" + + if not 0.0 <= mask_threshold <= 1.0: + raise ValueError("mask_threshold must be between 0 and 1.") + if size_threshold < 1: + raise ValueError("size_threshold must be at least 1.") + + image_tensor = validate_single_image(image, "Mask to SEGS") + height = int(image_tensor.shape[1]) + width = int(image_tensor.shape[2]) + source_mask = _validate_single_mask(mask, height, width) + working_mask = dilate_mask(source_mask, mask_dilation) + active_mask = working_mask >= mask_threshold + + segments: list[Segment] = [] + for component in connected_mask_components(active_mask): + if ( + component.bbox.width < size_threshold + or component.bbox.height < size_threshold + ): + continue + crop_region = crop_region_for_bbox( + component.bbox, + image_height=height, + image_width=width, + crop_factor=crop_factor, + ) + component_values = torch.where( + component.mask.to(device=working_mask.device), + working_mask, + torch.zeros_like(working_mask), + ) + cropped_segment_mask = crop_mask(component_values, crop_region).detach() + if post_dilation != 0: + cropped_segment_mask = dilate_mask(cropped_segment_mask, post_dilation) + segments.append( + Segment( + cropped_image=crop_image(image_tensor, crop_region) + .detach() + .clone(), + cropped_mask=cropped_segment_mask.clone(), + confidence=1.0, + crop_region=crop_region, + bbox=component.bbox, + label=str(label), + ) + ) + + LOGGER.debug( + "Built SEGS from mask", + extra={ + "operation": "mask_to_segs", + "mask_threshold": mask_threshold, + "size_threshold": size_threshold, + "mask_dilation": mask_dilation, + "post_dilation": post_dilation, + "crop_factor": crop_factor, + "segment_count": len(segments), + }, + ) + return (height, width), tuple(segments) + + +def _validate_single_mask(mask: object, height: int, width: int) -> torch.Tensor: + """Return a validated crop-source HW mask tensor.""" + + if not isinstance(mask, torch.Tensor): + raise TypeError("Mask to SEGS requires a torch MASK tensor.") + working = mask.float() + if working.ndim == 3: + batch_size = int(working.shape[0]) + if batch_size != 1: + raise ValueError( + "Mask to SEGS service requires one mask at a time; " + f"received batch size {batch_size}." + ) + working = working[0] + if working.ndim != 2: + raise ValueError("Mask to SEGS requires an HW or single-item BHW mask tensor.") + actual_height = int(working.shape[0]) + actual_width = int(working.shape[1]) + if actual_height != height or actual_width != width: + raise ValueError( + "Mask to SEGS requires mask dimensions to match the image; " + f"mask is {actual_height}x{actual_width}, image is {height}x{width}." + ) + return working.clamp(0.0, 1.0) diff --git a/simple_syrup/services/segs_output_service.py b/simple_syrup/services/segs_output_service.py index 0840805..94d0466 100644 --- a/simple_syrup/services/segs_output_service.py +++ b/simple_syrup/services/segs_output_service.py @@ -6,11 +6,20 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import torch -from ..domain.segs import BoundingBox, NativeSegs, Segment +from ..domain.segs import ( + BoundingBox, + ImpactSegs, + NativeSegs, + Segment, + limit_segs, + sort_segs, + to_impact_compatible_segs, +) from ..masking.segs_mask_ops import ( crop_image, crop_mask, @@ -27,6 +36,14 @@ class CombinedSegsResult: mask: torch.Tensor +@dataclass(frozen=True) +class FinalizedSegsOutput: + """Return Impact-compatible SEGS and its paired output mask.""" + + segs: ImpactSegs + mask: torch.Tensor + + def build_combined_segs_result( image: object, segs: NativeSegs, @@ -69,6 +86,31 @@ def build_combined_segs_result( return CombinedSegsResult(segs=(header, (combined_segment,)), mask=mask_output) +def finalize_detector_segs_output( + image: object, + segs: NativeSegs, + keep_only: int, + keep_by: str, + crop_factor: float, + sort_order: str, + combine_segs: bool, + combined_builder: Callable[ + [object, NativeSegs, float], + CombinedSegsResult, + ] = build_combined_segs_result, +) -> FinalizedSegsOutput: + """Apply shared detector-style SEGS output policy.""" + + limited_segs = limit_segs(segs, keep_only, keep_by) + sorted_segs = sort_segs(limited_segs, sort_order) + combined = combined_builder(image, sorted_segs, crop_factor) + output_segs = combined.segs if combine_segs else sorted_segs + return FinalizedSegsOutput( + segs=to_impact_compatible_segs(output_segs), + mask=combined.mask, + ) + + def combined_mask_from_segs(segs: NativeSegs) -> torch.Tensor: """Combine cropped segment masks into one full-image HW mask.""" diff --git a/tests/test_grounding_dino_loader.py b/tests/test_grounding_dino_loader.py index fb1ee0c..98cd07a 100644 --- a/tests/test_grounding_dino_loader.py +++ b/tests/test_grounding_dino_loader.py @@ -28,6 +28,18 @@ from simple_syrup.runtime.loaded_models import LoadedGroundingDINOModel from test_helpers import FakeFolderPaths +@dataclass +class _RecordingPhaseProgress: + """Record semantic model-load phases emitted by the runtime service.""" + + phases: list[str] = field(default_factory=list) + + def advance(self, phase: str) -> None: + """Record one phase transition.""" + + self.phases.append(phase) + + def test_grounding_dino_loader_resolves_explicit_layerstyle_bert( tmp_path: Path, ) -> None: @@ -114,6 +126,50 @@ def test_grounding_dino_loader_uses_process_cache_for_identical_model( assert len(cache) == 1 +def test_grounding_dino_loader_reports_cache_miss_and_hit_phases( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """GroundingDINO loads should expose construction and cache-hit boundaries.""" + + _install_fake_grounding_dino(monkeypatch) + _write_grounding_dino_artifacts(tmp_path) + _write_bert(tmp_path / "bert-base-uncased") + service = GroundingDINOLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache={}, + ) + cache_miss_progress = _RecordingPhaseProgress() + cache_hit_progress = _RecordingPhaseProgress() + + service.load_model( + "GroundingDINO_SwinT_OGC (694MB)", + TEXT_ENCODER_LAYERSTYLE, + auto_download=True, + phase_progress=cache_miss_progress, + ) + service.load_model( + "GroundingDINO_SwinT_OGC (694MB)", + TEXT_ENCODER_LAYERSTYLE, + auto_download=True, + phase_progress=cache_hit_progress, + ) + + assert cache_miss_progress.phases == [ + "resolving_artifacts", + "constructing_model", + "loading_checkpoint", + "loading_state_dict", + "registering_device_management", + "completed", + ] + assert cache_hit_progress.phases == [ + "resolving_artifacts", + "cache_hit", + "completed", + ] + + def test_grounding_dino_loader_invalidates_import_caches( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -194,13 +250,21 @@ def test_grounding_dino_loader_does_not_cache_failed_model_build( cache=cache, ) + phase_progress = _RecordingPhaseProgress() with pytest.raises(RuntimeError, match="GroundingDINO failed"): service.load_model( "GroundingDINO_SwinT_OGC (694MB)", TEXT_ENCODER_LAYERSTYLE, auto_download=True, + phase_progress=phase_progress, ) + assert phase_progress.phases == [ + "resolving_artifacts", + "constructing_model", + "failed", + ] + loaded = service.load_model( "GroundingDINO_SwinT_OGC (694MB)", TEXT_ENCODER_LAYERSTYLE, diff --git a/tests/test_mask_to_segs_node.py b/tests/test_mask_to_segs_node.py new file mode 100644 index 0000000..f9b6e4c --- /dev/null +++ b/tests/test_mask_to_segs_node.py @@ -0,0 +1,256 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Mask to SEGS Comfy v3 node.""" + +from __future__ import annotations + +from typing import Any, ClassVar, cast + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.nodes_v3.mask_to_segs import MaskToSEGSV3 +from simple_syrup.services.segs_output_service import CombinedSegsResult + + +def test_mask_to_segs_v3_schema() -> None: + """The v3 schema exposes the image-associated mask conversion contract.""" + + schema = MaskToSEGSV3.define_schema() + + assert schema.node_id == "SimpleSyrup.MaskToSEGS" + assert schema.display_name == "Mask to SEGS" + assert schema.category == "SimpleSyrup/Detection" + assert [input_item.id for input_item in schema.inputs] == [ + "image", + "mask", + "mask_threshold", + "size_threshold", + "keep_only", + "mask_dilation", + "post_dilation", + "crop_factor", + "sort_order", + "combine_segs", + "label", + ] + assert "confidence_threshold" not in {input_item.id for input_item in schema.inputs} + assert "detector_model" not in {input_item.id for input_item in schema.inputs} + assert "keep_by" not in {input_item.id for input_item in schema.inputs} + assert [input_item.io_type for input_item in schema.inputs[:2]] == [ + "IMAGE", + "MASK", + ] + assert schema.inputs[2].default == 0.5 + assert schema.inputs[3].default == 10 + assert schema.inputs[4].default == 0 + assert schema.inputs[5].default == 0 + assert schema.inputs[6].default == 0 + assert schema.inputs[7].default == 3.0 + assert schema.inputs[8].default == "largest to smallest" + assert schema.inputs[9].default is False + assert schema.inputs[10].default == "mask" + assert [output.id for output in schema.outputs] == ["segs", "mask"] + assert [output.io_type for output in schema.outputs] == ["SEGS", "MASK"] + assert schema.outputs[0].is_output_list is True + + +def test_mask_to_segs_execute_returns_separate_segs(monkeypatch: Any) -> None: + """Execution returns limited and sorted separate SEGS when combine is false.""" + + _FakeMaskService.responses = [_ranking_segs()] + _FakeMaskService.calls = [] + builder = _RecordingBuilder() + monkeypatch.setattr(MaskToSEGSV3, "service_class", _FakeMaskService) + monkeypatch.setattr(MaskToSEGSV3, "combined_builder", builder) + + segs, mask = MaskToSEGSV3.execute( + image=torch.zeros((1, 8, 8, 3), dtype=torch.float32), + mask=torch.zeros((1, 8, 8), dtype=torch.float32), + mask_threshold=0.5, + size_threshold=1, + keep_only=2, + mask_dilation=3, + post_dilation=-1, + crop_factor=1.0, + sort_order="left to right", + combine_segs=False, + label="paint", + ) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + _header, segments = segs_list[0] + assert [segment.label for segment in segments] == ["medium", "large-low"] + assert builder.seen_labels == [["medium", "large-low"]] + assert mask.shape == (1, 8, 8) + assert _FakeMaskService.calls[0]["mask_threshold"] == 0.5 + assert _FakeMaskService.calls[0]["mask_dilation"] == 3 + assert _FakeMaskService.calls[0]["post_dilation"] == -1 + assert _FakeMaskService.calls[0]["label"] == "paint" + + +def test_mask_to_segs_execute_returns_combined_segs(monkeypatch: Any) -> None: + """Execution returns one combined SEG when combine is enabled.""" + + _FakeMaskService.responses = [_ranking_segs()] + _FakeMaskService.calls = [] + builder = _RecordingBuilder() + monkeypatch.setattr(MaskToSEGSV3, "service_class", _FakeMaskService) + monkeypatch.setattr(MaskToSEGSV3, "combined_builder", builder) + + segs, mask = MaskToSEGSV3.execute( + image=torch.zeros((1, 8, 8, 3), dtype=torch.float32), + mask=torch.zeros((8, 8), dtype=torch.float32), + mask_threshold=0.5, + size_threshold=1, + keep_only=1, + mask_dilation=0, + post_dilation=0, + crop_factor=1.0, + sort_order="largest to smallest", + combine_segs=True, + label="paint", + ) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + _header, segments = segs_list[0] + assert [segment.label for segment in segments] == ["combined"] + assert builder.seen_labels == [["large-low"]] + assert torch.equal(mask, torch.ones((1, 8, 8))) + + +def test_mask_to_segs_execute_reuses_single_mask_for_image_batch( + monkeypatch: Any, +) -> None: + """A single mask can be reused for every image in a batch.""" + + _FakeMaskService.responses = [ + _segs(_segment("image-0", CropRegion(0, 0, 2, 2))), + _segs(_segment("image-1", CropRegion(0, 0, 2, 2))), + ] + _FakeMaskService.calls = [] + monkeypatch.setattr(MaskToSEGSV3, "service_class", _FakeMaskService) + monkeypatch.setattr(MaskToSEGSV3, "combined_builder", _RecordingBuilder()) + + segs, mask = MaskToSEGSV3.execute( + image=torch.zeros((2, 8, 8, 3), dtype=torch.float32), + mask=torch.zeros((1, 8, 8), dtype=torch.float32), + mask_threshold=0.5, + size_threshold=1, + keep_only=0, + mask_dilation=0, + post_dilation=0, + crop_factor=1.0, + sort_order="largest to smallest", + combine_segs=False, + label="paint", + ) + + segs_list = cast(list[tuple[object, list[Segment]]], segs) + assert [segments[0].label for _header, segments in segs_list] == [ + "image-0", + "image-1", + ] + assert len(_FakeMaskService.calls) == 2 + assert mask.shape == (2, 8, 8) + + +def test_mask_to_segs_execute_rejects_mismatched_mask_batch() -> None: + """Mask batches must be singleton or aligned to the image batch.""" + + with pytest.raises(ValueError, match="mask batch size"): + MaskToSEGSV3.execute( + image=torch.zeros((2, 8, 8, 3), dtype=torch.float32), + mask=torch.zeros((3, 8, 8), dtype=torch.float32), + mask_threshold=0.5, + size_threshold=1, + keep_only=0, + mask_dilation=0, + post_dilation=0, + crop_factor=1.0, + sort_order="largest to smallest", + combine_segs=False, + label="paint", + ) + + +class _FakeMaskService: + """Mask-to-SEGS service double for node tests.""" + + responses: ClassVar[list[NativeSegs]] = [] + calls: ClassVar[list[dict[str, object]]] = [] + + def __init__(self) -> None: + """Create an empty per-instance response cursor.""" + + self._index = 0 + + def build(self, **kwargs: object) -> NativeSegs: + """Return the next configured SEGS payload and record inputs.""" + + type(self).calls.append(kwargs) + response = type(self).responses[self._index] + self._index += 1 + return response + + +class _RecordingBuilder: + """Combined-result builder that records input SEGS order.""" + + def __init__(self) -> None: + """Create empty call records.""" + + self.seen_labels: list[list[str]] = [] + + def __call__( + self, + image: object, + segs: NativeSegs, + crop_factor: float, + ) -> CombinedSegsResult: + """Return a deterministic combined result and record source labels.""" + + del image, crop_factor + header, segments = segs + self.seen_labels.append([segment.label for segment in segments]) + return CombinedSegsResult( + segs=(header, (_segment("combined", CropRegion(0, 0, 8, 8)),)), + mask=torch.ones((1, header[0], header[1]), dtype=torch.float32), + ) + + +def _ranking_segs() -> NativeSegs: + """Return SEGS with rankable area values.""" + + return _segs( + _segment("small-high", CropRegion(0, 0, 2, 2)), + _segment("large-low", CropRegion(4, 0, 8, 4)), + _segment("medium", CropRegion(2, 0, 5, 3)), + ) + + +def _segs(*segments: Segment) -> NativeSegs: + """Create native test SEGS.""" + + return (8, 8), tuple(segments) + + +def _segment(label: str, crop_region: CropRegion) -> Segment: + """Create a test segment.""" + + return Segment( + cropped_image=None, + cropped_mask=torch.ones((crop_region.height, crop_region.width)), + confidence=1.0, + crop_region=crop_region, + bbox=BoundingBox( + crop_region.left, + crop_region.top, + crop_region.right, + crop_region.bottom, + ), + label=label, + ) diff --git a/tests/test_mask_to_segs_service.py b/tests/test_mask_to_segs_service.py new file mode 100644 index 0000000..09e815e --- /dev/null +++ b/tests/test_mask_to_segs_service.py @@ -0,0 +1,304 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for converting existing masks into image-associated SEGS.""" + +from __future__ import annotations + +from typing import cast + +import pytest +import torch + +from simple_syrup.domain.segs import CropRegion +from simple_syrup.services.mask_to_segs_service import MaskToSEGSService + + +def test_rectangular_mask_creates_image_associated_seg() -> None: + """A single active mask region becomes one SEG with matching image crop.""" + + image = _image() + mask = torch.zeros((6, 6), dtype=torch.float32) + mask[1:4, 2:5] = 1.0 + + header, segments = MaskToSEGSService().build( + image=image, + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=0, + post_dilation=0, + crop_factor=1.0, + label="paint", + ) + + assert header == (6, 6) + assert len(segments) == 1 + segment = segments[0] + assert segment.crop_region == CropRegion(2, 1, 5, 4) + assert segment.bbox == (2, 1, 5, 4) + assert segment.label == "paint" + assert segment.confidence == 1.0 + assert torch.equal(cast(torch.Tensor, segment.cropped_image), image[:, 1:4, 2:5]) + assert torch.equal(cast(torch.Tensor, segment.cropped_mask), torch.ones((3, 3))) + + +def test_disconnected_regions_create_separate_segs() -> None: + """Separated mask islands become separate SEGS in scan order.""" + + mask = torch.zeros((6, 6), dtype=torch.float32) + mask[0:2, 0:2] = 1.0 + mask[4:6, 4:6] = 1.0 + + _header, segments = MaskToSEGSService().build( + image=_image(), + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=0, + post_dilation=0, + crop_factor=1.0, + label="mask", + ) + + assert [segment.crop_region for segment in segments] == [ + CropRegion(0, 0, 2, 2), + CropRegion(4, 4, 6, 6), + ] + + +def test_diagonal_contact_uses_eight_connected_components() -> None: + """Diagonally touching pixels are treated as one painted region.""" + + mask = torch.zeros((4, 4), dtype=torch.float32) + mask[1, 1] = 1.0 + mask[2, 2] = 1.0 + + _header, segments = MaskToSEGSService().build( + image=torch.zeros((1, 4, 4, 3), dtype=torch.float32), + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=0, + post_dilation=0, + crop_factor=1.0, + label="mask", + ) + + assert len(segments) == 1 + assert segments[0].bbox == (1, 1, 3, 3) + + +def test_empty_mask_returns_empty_native_segs() -> None: + """Empty masks produce an empty native SEGS payload.""" + + header, segments = MaskToSEGSService().build( + image=_image(), + mask=torch.zeros((6, 6), dtype=torch.float32), + mask_threshold=0.5, + size_threshold=1, + mask_dilation=0, + post_dilation=0, + crop_factor=1.0, + label="mask", + ) + + assert header == (6, 6) + assert segments == () + + +def test_mask_threshold_controls_active_pixels() -> None: + """Soft pixels below threshold do not become SEG regions.""" + + mask = torch.zeros((4, 4), dtype=torch.float32) + mask[1, 1] = 0.4 + mask[2, 2] = 0.6 + + _header, segments = MaskToSEGSService().build( + image=torch.zeros((1, 4, 4, 3), dtype=torch.float32), + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=0, + post_dilation=0, + crop_factor=1.0, + label="mask", + ) + + assert len(segments) == 1 + assert segments[0].bbox == (2, 2, 3, 3) + assert cast(torch.Tensor, segments[0].cropped_mask).item() == pytest.approx(0.6) + + +def test_mask_dilation_grows_source_mask_before_extraction() -> None: + """Positive mask dilation expands topology before bounding boxes are built.""" + + mask = torch.zeros((7, 7), dtype=torch.float32) + mask[3, 3] = 1.0 + + _header, segments = MaskToSEGSService().build( + image=torch.zeros((1, 7, 7, 3), dtype=torch.float32), + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=3, + post_dilation=0, + crop_factor=1.0, + label="mask", + ) + + assert segments[0].bbox == (2, 2, 5, 5) + assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 9.0 + + +def test_negative_mask_dilation_erodes_source_mask() -> None: + """Negative mask dilation shrinks active regions before extraction.""" + + mask = torch.zeros((7, 7), dtype=torch.float32) + mask[1:6, 1:6] = 1.0 + + _header, segments = MaskToSEGSService().build( + image=torch.zeros((1, 7, 7, 3), dtype=torch.float32), + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=-3, + post_dilation=0, + crop_factor=1.0, + label="mask", + ) + + assert segments[0].bbox == (2, 2, 5, 5) + assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 9.0 + + +def test_post_dilation_changes_crop_local_mask_after_crop() -> None: + """Post dilation applies to each final cropped SEG mask.""" + + mask = torch.zeros((7, 7), dtype=torch.float32) + mask[2:5, 2:5] = 1.0 + + _header, segments = MaskToSEGSService().build( + image=torch.zeros((1, 7, 7, 3), dtype=torch.float32), + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=0, + post_dilation=-3, + crop_factor=3.0, + label="mask", + ) + + assert cast(torch.Tensor, segments[0].cropped_mask).sum().item() == 1.0 + + +def test_crop_factor_expands_crop_region() -> None: + """Crop factor adds image context around the extracted mask bbox.""" + + mask = torch.zeros((8, 8), dtype=torch.float32) + mask[3:5, 3:5] = 1.0 + + _header, segments = MaskToSEGSService().build( + image=torch.zeros((1, 8, 8, 3), dtype=torch.float32), + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=0, + post_dilation=0, + crop_factor=3.0, + label="mask", + ) + + assert segments[0].crop_region == CropRegion(1, 1, 7, 7) + assert cast(torch.Tensor, segments[0].cropped_mask).shape == (6, 6) + + +def test_crop_factor_zero_uses_full_image() -> None: + """A zero crop factor selects the full image as the SEG crop.""" + + mask = torch.zeros((4, 4), dtype=torch.float32) + mask[1:3, 1:3] = 1.0 + + _header, segments = MaskToSEGSService().build( + image=torch.zeros((1, 4, 4, 3), dtype=torch.float32), + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=0, + post_dilation=0, + crop_factor=0.0, + label="mask", + ) + + assert segments[0].crop_region == CropRegion(0, 0, 4, 4) + assert cast(torch.Tensor, segments[0].cropped_mask).shape == (4, 4) + + +def test_fractional_crop_factor_below_one_fails() -> None: + """Crop factor accepts zero or values at least one, but not in between.""" + + mask = torch.zeros((4, 4), dtype=torch.float32) + mask[1:3, 1:3] = 1.0 + + with pytest.raises(ValueError, match="crop_factor"): + MaskToSEGSService().build( + image=torch.zeros((1, 4, 4, 3), dtype=torch.float32), + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=0, + post_dilation=0, + crop_factor=0.5, + label="mask", + ) + + +def test_size_threshold_drops_small_components() -> None: + """Regions below the minimum bbox size are discarded.""" + + mask = torch.zeros((6, 6), dtype=torch.float32) + mask[1:3, 1:3] = 1.0 + + _header, segments = MaskToSEGSService().build( + image=_image(), + mask=mask, + mask_threshold=0.5, + size_threshold=3, + mask_dilation=0, + post_dilation=0, + crop_factor=1.0, + label="mask", + ) + + assert segments == () + + +def test_soft_mask_values_are_retained_inside_component() -> None: + """SEG masks preserve source mask strength inside extracted components.""" + + mask = torch.zeros((4, 4), dtype=torch.float32) + mask[1, 1] = 0.75 + mask[1, 2] = 0.5 + + _header, segments = MaskToSEGSService().build( + image=torch.zeros((1, 4, 4, 3), dtype=torch.float32), + mask=mask, + mask_threshold=0.5, + size_threshold=1, + mask_dilation=0, + post_dilation=0, + crop_factor=1.0, + label="mask", + ) + + assert torch.equal( + cast(torch.Tensor, segments[0].cropped_mask), + torch.tensor([[0.75, 0.5]], dtype=torch.float32), + ) + + +def _image() -> torch.Tensor: + """Return a deterministic 6x6 test image.""" + + return torch.arange(1 * 6 * 6 * 3, dtype=torch.float32).reshape(1, 6, 6, 3) / 255 diff --git a/tests/test_persisted_widget_order_contract.py b/tests/test_persisted_widget_order_contract.py new file mode 100644 index 0000000..c748c32 --- /dev/null +++ b/tests/test_persisted_widget_order_contract.py @@ -0,0 +1,259 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Keep persisted public widget positions append-only for established nodes.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Protocol + +import pytest + +from simple_syrup.nodes.detail_segs_as_regions import DetailSEGSAsRegions +from simple_syrup.nodes.detail_segs_by_scale_factor import DetailSEGSByScaleFactor +from simple_syrup.nodes.detail_segs_by_scale_factor_tiled_diffusion import ( + DetailSEGSByScaleFactorTiledDiffusion, +) +from simple_syrup.nodes.detect_segs_with_ultralytics import DetectSEGSWithUltralytics +from simple_syrup.nodes.grounding_dino_model_loader import GroundingDINOModelLoader +from simple_syrup.nodes.image_resize_to_target import ResizeImageToTarget +from simple_syrup.nodes.ksampler_extras import KSamplerExtras +from simple_syrup.nodes.ksampler_tiled_diffusion import KSamplerTiledDiffusion +from simple_syrup.nodes.load_ultralytics_model import LoadUltralyticsModel +from simple_syrup.nodes.prompt_encode_style import PromptEncodeStyle +from simple_syrup.nodes.prompt_segs_with_sam import PromptSEGSWithSAM +from simple_syrup.nodes.sam_model_loader import SAMModelLoader +from simple_syrup.nodes.schedule_and_encode_prompts_with_prompt_control import ( + ScheduleAndEncodePromptsWithPromptControl, +) +from simple_syrup.nodes.seed import Seed +from simple_syrup.nodes.simple_load_anima import SimpleLoadAnima +from simple_syrup.nodes.simple_load_checkpoint import SimpleLoadCheckpoint +from simple_syrup.nodes.vae_options import VAEDecodeOptions, VAEEncodeOptions +from simple_syrup.nodes.vitmatte_model_loader import ViTMatteModelLoader + +_WIDGET_TYPES = frozenset( + {"BOOLEAN", "COMBO", "FLOAT", "INT", "LIST", "NUMBER", "STRING", "TEXT"} +) + + +class _ClassicNode(Protocol): + """Describe the persisted classic Comfy input declaration contract.""" + + @classmethod + def INPUT_TYPES(cls) -> Mapping[str, object]: + """Return the node's ordered classic Comfy inputs.""" + + +_PERSISTED_WIDGET_PREFIXES: tuple[tuple[type[_ClassicNode], tuple[str, ...]], ...] = ( + ( + DetailSEGSAsRegions, + ( + "global_prompt_weight", + "scale_factor", + "upscale_method", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + "feather", + "noise_mask", + "noise_mask_feather", + "tiled_encode", + "tiled_decode", + ), + ), + ( + DetailSEGSByScaleFactor, + ( + "scale_factor", + "upscale_method", + "clamp_size", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + "feather", + "noise_mask", + "noise_mask_feather", + "tiled_encode", + "tiled_decode", + ), + ), + ( + DetailSEGSByScaleFactorTiledDiffusion, + ( + "scale_factor", + "upscale_method", + "clamp_size", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + "feather", + "noise_mask", + "noise_mask_feather", + "tiled_encode", + "tiled_decode", + "diffusion_mode", + "latent_tile_width", + "latent_tile_height", + "latent_tile_overlap", + "latent_tile_batch_size", + ), + ), + ( + DetectSEGSWithUltralytics, + ( + "confidence_threshold", + "size_threshold", + "keep_only", + "keep_by", + "bbox_dilation", + "sub_dilation", + "post_dilation", + "crop_factor", + "sort_order", + "combine_segs", + ), + ), + (GroundingDINOModelLoader, ("grounding_dino_model", "text_encoder")), + ( + KSamplerExtras, + ("seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"), + ), + ( + KSamplerTiledDiffusion, + ( + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + "diffusion_mode", + "latent_tile_width", + "latent_tile_height", + "latent_tile_overlap", + "latent_tile_batch_size", + ), + ), + (LoadUltralyticsModel, ("model_name",)), + (PromptEncodeStyle, ("encode_style",)), + ( + PromptSEGSWithSAM, + ( + "positive_prompt", + "negative_prompt", + "confidence_threshold", + "size_threshold", + "keep_only", + "keep_by", + "bbox_dilation", + "mask_dilation", + "detail_method", + "detail_erode", + "detail_dilate", + "black_point", + "white_point", + "refine_mask", + "mask_refinement_max_size", + "execution_device", + "crop_factor", + "sort_order", + "combine_segs", + ), + ), + ( + ResizeImageToTarget, + ( + "width", + "height", + "resize_mode", + "sampling", + "processor", + "divisible_by", + "crop_position", + "pad_color", + "max_batch_size", + "sinc_window", + "precision", + ), + ), + (SAMModelLoader, ("sam_model",)), + ( + ScheduleAndEncodePromptsWithPromptControl, + ("positive_prompt", "negative_prompt", "encode_style"), + ), + (Seed, ("seed",)), + ( + SimpleLoadAnima, + ( + "diffusion_model", + "diffusion_weight_dtype", + "text_encoder", + "text_encoder_device", + "vae", + ), + ), + (SimpleLoadCheckpoint, ("ckpt_name", "vae_name", "clip_skip")), + ( + VAEDecodeOptions, + ("use_tiling", "tile_size", "overlap", "temporal_size", "temporal_overlap"), + ), + ( + VAEEncodeOptions, + ("use_tiling", "tile_size", "overlap", "temporal_size", "temporal_overlap"), + ), + (ViTMatteModelLoader, ("vitmatte_model",)), +) + + +@pytest.mark.parametrize(("node_class", "persisted_prefix"), _PERSISTED_WIDGET_PREFIXES) +def test_established_widget_inputs_remain_an_append_only_prefix( + node_class: type[_ClassicNode], + persisted_prefix: tuple[str, ...], +) -> None: + """Fail when an established widget is inserted, removed, or reordered.""" + + actual = _widget_input_names(node_class.INPUT_TYPES()) + + assert actual[: len(persisted_prefix)] == persisted_prefix + + +def _widget_input_names(input_types: Mapping[str, object]) -> tuple[str, ...]: + """Return classic Comfy widget inputs in their persisted positional order.""" + + names: list[str] = [] + for section_name in ("required", "optional"): + section = input_types.get(section_name) + if not isinstance(section, Mapping): + continue + for input_name, field_spec in section.items(): + if _is_widget_field(field_spec): + names.append(str(input_name)) + return tuple(names) + + +def _is_widget_field(field_spec: object) -> bool: + """Return whether a classic Comfy field serializes a widget value.""" + + if not isinstance(field_spec, Sequence) or isinstance(field_spec, str | bytes): + return False + if not field_spec: + return False + input_type = field_spec[0] + return ( + isinstance(input_type, Sequence) + and not isinstance(input_type, str | bytes) + or isinstance(input_type, str) + and input_type.upper() in _WIDGET_TYPES + ) diff --git a/tests/test_phase_progress.py b/tests/test_phase_progress.py new file mode 100644 index 0000000..f5654bf --- /dev/null +++ b/tests/test_phase_progress.py @@ -0,0 +1,84 @@ +"""Tests for standard Comfy phase-progress publication.""" + +from __future__ import annotations + +from types import ModuleType + +import pytest + +from simple_syrup.runtime.progress import ComfyPhaseProgressReporter + + +def test_comfy_phase_progress_publishes_boundaries_and_terminal_completion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Named phases should use standard Comfy progress and finish terminally.""" + + updates: list[tuple[int, int | None]] = [] + + class _ProgressBar: + """Record standard Comfy absolute progress updates.""" + + def __init__(self, total: int) -> None: + """Record the configured total.""" + + assert total == 4 + + def update_absolute(self, value: int, total: int | None = None) -> None: + """Record one absolute progress update.""" + + updates.append((value, total)) + + comfy_utils = ModuleType("comfy.utils") + comfy_utils.ProgressBar = _ProgressBar # type: ignore[attr-defined] + monkeypatch.setattr( + "simple_syrup.runtime.progress.import_module", + lambda _name: comfy_utils, + ) + reporter = ComfyPhaseProgressReporter( + operation="model_load", + subject="model-a", + total_phases=4, + ) + + reporter.advance("resolving") + reporter.advance("cache_hit") + reporter.advance("completed") + + assert updates == [(1, 4), (2, 4), (4, 4)] + + +def test_comfy_phase_progress_does_not_present_failure_as_completion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed phase should leave room for Comfy's terminal error state.""" + + updates: list[tuple[int, int | None]] = [] + + class _ProgressBar: + """Record standard Comfy absolute progress updates.""" + + def __init__(self, _total: int) -> None: + """Accept the configured total.""" + + def update_absolute(self, value: int, total: int | None = None) -> None: + """Record one absolute progress update.""" + + updates.append((value, total)) + + comfy_utils = ModuleType("comfy.utils") + comfy_utils.ProgressBar = _ProgressBar # type: ignore[attr-defined] + monkeypatch.setattr( + "simple_syrup.runtime.progress.import_module", + lambda _name: comfy_utils, + ) + reporter = ComfyPhaseProgressReporter( + operation="model_load", + subject="model-a", + total_phases=4, + ) + + reporter.advance("resolving") + reporter.advance("failed") + + assert updates == [(1, 4), (2, 4)] diff --git a/tests/test_registration.py b/tests/test_registration.py index 9591e92..e33d4a1 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -34,6 +34,7 @@ BASE_NODE_IDS = [ "SimpleSyrup.LatentDiagnostics", "SimpleSyrup.LayerStyleSAMModelsAdapter", "SimpleSyrup.LoadUltralyticsModel", + "SimpleSyrup.MaskToSEGS", "SimpleSyrup.PromptEncodeStyleAndNormalization", "SimpleSyrup.PromptEncodeStyle", "SimpleSyrup.PromptSEGSWithSAM", diff --git a/tests/test_sam_loader.py b/tests/test_sam_loader.py index bc454b8..c1bf50a 100644 --- a/tests/test_sam_loader.py +++ b/tests/test_sam_loader.py @@ -19,6 +19,18 @@ from simple_syrup.runtime.sam_loader import SAMLoaderService, SAMModelCacheKey from test_helpers import FakeFolderPaths +@dataclass +class _RecordingPhaseProgress: + """Record semantic SAM load phases emitted by the runtime service.""" + + phases: list[str] = field(default_factory=list) + + def advance(self, phase: str) -> None: + """Record one phase transition.""" + + self.phases.append(phase) + + class RecordingDownloader: """Downloader double that writes requested artifacts.""" @@ -81,6 +93,45 @@ def test_sam_loader_uses_process_cache_for_identical_resolved_model( assert len(cache) == 1 +def test_sam_loader_reports_cache_miss_and_hit_phases( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SAM loads should expose checkpoint construction and cache reuse.""" + + _install_fake_segment_anything(monkeypatch) + _create_sam_file(tmp_path, "sam_vit_b_01ec64.pth") + service = SAMLoaderService( + folder_paths_module=FakeFolderPaths(tmp_path), + cache={}, + ) + cache_miss_progress = _RecordingPhaseProgress() + cache_hit_progress = _RecordingPhaseProgress() + + service.load_model( + "sam_vit_b (375MB)", + auto_download=True, + phase_progress=cache_miss_progress, + ) + service.load_model( + "sam_vit_b (375MB)", + auto_download=True, + phase_progress=cache_hit_progress, + ) + + assert cache_miss_progress.phases == [ + "resolving_artifacts", + "loading_checkpoint", + "registering_device_management", + "completed", + ] + assert cache_hit_progress.phases == [ + "resolving_artifacts", + "cache_hit", + "completed", + ] + + def test_sam_loader_cache_separates_model_selections( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -121,8 +172,19 @@ def test_sam_loader_does_not_cache_failed_registry_load( cache=cache, ) + phase_progress = _RecordingPhaseProgress() with pytest.raises(RuntimeError, match="SAM failed"): - service.load_model("sam_vit_b (375MB)", auto_download=True) + service.load_model( + "sam_vit_b (375MB)", + auto_download=True, + phase_progress=phase_progress, + ) + + assert phase_progress.phases == [ + "resolving_artifacts", + "loading_checkpoint", + "failed", + ] loaded = service.load_model("sam_vit_b (375MB)", auto_download=True) diff --git a/tests/test_segs_output_service.py b/tests/test_segs_output_service.py new file mode 100644 index 0000000..094ccea --- /dev/null +++ b/tests/test_segs_output_service.py @@ -0,0 +1,141 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for shared SEGS output finalization.""" + +from __future__ import annotations + +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.services.segs_output_service import ( + CombinedSegsResult, + finalize_detector_segs_output, +) + + +def test_finalization_limits_then_sorts_before_combined_builder() -> None: + """The shared pipeline limits SEGS before applying final output ordering.""" + + builder = _RecordingBuilder() + output = finalize_detector_segs_output( + image=torch.zeros((1, 8, 8, 3), dtype=torch.float32), + segs=_ranking_segs(), + keep_only=2, + keep_by="largest size", + crop_factor=1.5, + sort_order="left to right", + combine_segs=False, + combined_builder=builder, + ) + + _header, segments = output.segs + assert isinstance(segments, list) + assert [segment.label for segment in segments] == ["medium", "large-low"] + assert builder.seen_labels == [["medium", "large-low"]] + assert builder.seen_crop_factors == [1.5] + assert torch.equal(output.mask, torch.ones((1, 8, 8), dtype=torch.float32)) + + +def test_finalization_returns_combined_segs_when_requested() -> None: + """Combined mode emits the unioned SEGS while keeping the same output mask.""" + + builder = _RecordingBuilder() + output = finalize_detector_segs_output( + image=torch.zeros((1, 8, 8, 3), dtype=torch.float32), + segs=_ranking_segs(), + keep_only=1, + keep_by="highest confidence", + crop_factor=1.0, + sort_order="largest to smallest", + combine_segs=True, + combined_builder=builder, + ) + + _header, segments = output.segs + assert [segment.label for segment in segments] == ["combined"] + assert builder.seen_labels == [["small-high"]] + assert torch.equal(output.mask, torch.ones((1, 8, 8), dtype=torch.float32)) + + +def test_finalization_supports_largest_size_for_mask_derived_segs() -> None: + """Mask-derived SEGS can use the existing largest-size ranking policy.""" + + builder = _RecordingBuilder() + output = finalize_detector_segs_output( + image=torch.zeros((1, 8, 8, 3), dtype=torch.float32), + segs=_ranking_segs(), + keep_only=1, + keep_by="largest size", + crop_factor=1.0, + sort_order="largest to smallest", + combine_segs=False, + combined_builder=builder, + ) + + _header, segments = output.segs + assert [segment.label for segment in segments] == ["large-low"] + assert builder.seen_labels == [["large-low"]] + + +class _RecordingBuilder: + """Combined-result builder test double.""" + + def __init__(self) -> None: + """Create empty call records.""" + + self.seen_labels: list[list[str]] = [] + self.seen_crop_factors: list[float] = [] + + def __call__( + self, + image: object, + segs: NativeSegs, + crop_factor: float, + ) -> CombinedSegsResult: + """Return a deterministic combined output while recording inputs.""" + + del image + self.seen_crop_factors.append(crop_factor) + self.seen_labels.append([segment.label for segment in segs[1]]) + header, _segments = segs + return CombinedSegsResult( + segs=(header, (_segment("combined", CropRegion(0, 0, 8, 8), 1.0),)), + mask=torch.ones((1, header[0], header[1]), dtype=torch.float32), + ) + + +def _ranking_segs() -> NativeSegs: + """Return SEGS with rankable confidence and size differences.""" + + return ( + (8, 8), + ( + _segment("small-high", CropRegion(0, 0, 2, 2), 0.95), + _segment("large-low", CropRegion(4, 0, 8, 4), 0.1), + _segment("medium", CropRegion(2, 0, 5, 3), 0.5), + ), + ) + + +def _segment( + label: str, + crop_region: CropRegion, + confidence: float, +) -> Segment: + """Create a test segment with a crop-local mask.""" + + return Segment( + cropped_image=None, + cropped_mask=torch.ones((crop_region.height, crop_region.width)), + confidence=confidence, + crop_region=crop_region, + bbox=BoundingBox( + crop_region.left, + crop_region.top, + crop_region.right, + crop_region.bottom, + ), + label=label, + ) From f1fef914e3b16bd9a7793153b40afcb424539075 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Mon, 13 Jul 2026 20:54:00 -0400 Subject: [PATCH 19/40] fix(groundingdino): support transformers v4 and v5 --- .github/workflows/release.yml | 38 +++ .../runtime/grounding_dino_bert_adapter.py | 91 ++++++ .../models/GroundingDINO/bertwarper.py | 270 ------------------ .../models/GroundingDINO/groundingdino.py | 20 +- .../models/GroundingDINO/text_token_masks.py | 64 +++++ tests/test_grounding_dino_bert_adapter.py | 163 +++++++++++ tests/test_grounding_dino_text_token_masks.py | 83 ++++++ tests/test_phase_progress.py | 4 + third_party/manifest.toml | 2 +- 9 files changed, 455 insertions(+), 280 deletions(-) create mode 100644 simple_syrup/runtime/grounding_dino_bert_adapter.py delete mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/bertwarper.py create mode 100644 simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/text_token_masks.py create mode 100644 tests/test_grounding_dino_bert_adapter.py create mode 100644 tests/test_grounding_dino_text_token_masks.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7764e1..94edd7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,7 @@ name: release on: + pull_request: push: branches: - main @@ -19,7 +20,44 @@ 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 + run: >- + python -m pytest -q + tests/test_grounding_dino_bert_adapter.py + tests/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" diff --git a/simple_syrup/runtime/grounding_dino_bert_adapter.py b/simple_syrup/runtime/grounding_dino_bert_adapter.py new file mode 100644 index 0000000..9615ebc --- /dev/null +++ b/simple_syrup/runtime/grounding_dino_bert_adapter.py @@ -0,0 +1,91 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Adapt GroundingDINO attention masks to supported Transformers BERT APIs.""" + +from __future__ import annotations + +import torch +import transformers +from packaging.specifiers import SpecifierSet +from packaging.version import InvalidVersion, Version +from torch import nn + +SUPPORTED_TRANSFORMERS = SpecifierSet(">=4.50.3,<6") +SUPPORTED_TRANSFORMERS_LABEL = "Transformers >=4.50.3,<6" + + +def prepare_grounding_dino_bert_attention_mask( + attention_mask: torch.Tensor, + *, + text_encoder: nn.Module, + transformers_version: str | None = None, +) -> torch.Tensor: + """Prepare one GroundingDINO mask for the installed native BERT forward API. + + GroundingDINO supplies a three-dimensional Boolean sub-sentence mask. The + final Transformers v4 line accepts that representation directly, while v5 + accepts an already prepared four-dimensional additive mask. Ordinary + two-dimensional tokenizer padding masks remain owned by native BERT. + + Args: + attention_mask: Tokenizer padding mask or GroundingDINO block mask. + text_encoder: BERT module whose parameter dtype determines mask dtype. + transformers_version: Explicit version used by deterministic tests. + + Returns: + The representation accepted by the selected native BERT generation. + + Raises: + RuntimeError: The Transformers version is outside the supported range. + ValueError: The GroundingDINO block mask or encoder dtype is invalid. + """ + + version = _validated_transformers_version( + transformers_version or transformers.__version__ + ) + if attention_mask.ndim == 2: + return attention_mask + if attention_mask.ndim != 3: + raise ValueError( + "GroundingDINO BERT attention masks must have two or three dimensions." + ) + if attention_mask.dtype is not torch.bool: + raise ValueError("GroundingDINO sub-sentence attention masks must be boolean.") + if attention_mask.shape[-2] != attention_mask.shape[-1]: + raise ValueError("GroundingDINO sub-sentence attention masks must be square.") + if version.major == 4: + return attention_mask + + parameter = next(text_encoder.parameters(), None) + if parameter is None or not parameter.dtype.is_floating_point: + raise ValueError( + "GroundingDINO's BERT text encoder must have floating-point parameters." + ) + allowed_tokens = attention_mask.unsqueeze(1) + blocked_value = torch.finfo(parameter.dtype).min + return torch.full( + allowed_tokens.shape, + blocked_value, + dtype=parameter.dtype, + device=attention_mask.device, + ).masked_fill(allowed_tokens, 0.0) + + +def _validated_transformers_version(version_text: str) -> Version: + """Parse and enforce the Transformers API generations verified by the project.""" + + try: + version = Version(version_text) + except InvalidVersion as error: + raise RuntimeError( + f"{SUPPORTED_TRANSFORMERS_LABEL} is required for GroundingDINO; " + f"found invalid version {version_text!r}." + ) from error + if version not in SUPPORTED_TRANSFORMERS: + raise RuntimeError( + f"{SUPPORTED_TRANSFORMERS_LABEL} is required for GroundingDINO; " + f"found {version}." + ) + return version diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/bertwarper.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/bertwarper.py deleted file mode 100644 index d340836..0000000 --- a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/bertwarper.py +++ /dev/null @@ -1,270 +0,0 @@ -# ------------------------------------------------------------------------ -# Grounding DINO -# url: https://github.com/IDEA-Research/GroundingDINO -# Copyright (c) 2023 IDEA. All Rights Reserved. -# Licensed under the Apache License, Version 2.0 [see LICENSE for details] -# ------------------------------------------------------------------------ - -import torch -from torch import nn -from transformers.modeling_outputs import BaseModelOutputWithPoolingAndCrossAttentions - - -class BertModelWarper(nn.Module): - def __init__(self, bert_model): - super().__init__() - # self.bert = bert_modelc - - self.config = bert_model.config - self.embeddings = bert_model.embeddings - self.encoder = bert_model.encoder - self.pooler = bert_model.pooler - - self.get_extended_attention_mask = bert_model.get_extended_attention_mask - self.invert_attention_mask = bert_model.invert_attention_mask - self.get_head_mask = bert_model.get_head_mask - - def forward( - self, - input_ids=None, - attention_mask=None, - token_type_ids=None, - position_ids=None, - head_mask=None, - inputs_embeds=None, - encoder_hidden_states=None, - encoder_attention_mask=None, - past_key_values=None, - use_cache=None, - output_attentions=None, - output_hidden_states=None, - return_dict=None, - ): - r""" - encoder_hidden_states (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length, hidden_size)`, `optional`): - Sequence of hidden-states at the output of the last layer of the encoder. Used in the cross-attention if - the model is configured as a decoder. - encoder_attention_mask (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length)`, `optional`): - Mask to avoid performing attention on the padding token indices of the encoder input. This mask is used in - the cross-attention if the model is configured as a decoder. Mask values selected in ``[0, 1]``: - - - 1 for tokens that are **not masked**, - - 0 for tokens that are **masked**. - past_key_values (:obj:`tuple(tuple(torch.FloatTensor))` of length :obj:`config.n_layers` with each tuple having 4 tensors of shape :obj:`(batch_size, num_heads, sequence_length - 1, embed_size_per_head)`): - Contains precomputed key and value hidden states of the attention blocks. Can be used to speed up decoding. - - If :obj:`past_key_values` are used, the user can optionally input only the last :obj:`decoder_input_ids` - (those that don't have their past key value states given to this model) of shape :obj:`(batch_size, 1)` - instead of all :obj:`decoder_input_ids` of shape :obj:`(batch_size, sequence_length)`. - use_cache (:obj:`bool`, `optional`): - If set to :obj:`True`, :obj:`past_key_values` key value states are returned and can be used to speed up - decoding (see :obj:`past_key_values`). - """ - output_attentions = ( - output_attentions if output_attentions is not None else self.config.output_attentions - ) - output_hidden_states = ( - output_hidden_states - if output_hidden_states is not None - else self.config.output_hidden_states - ) - return_dict = return_dict if return_dict is not None else self.config.use_return_dict - - if self.config.is_decoder: - use_cache = use_cache if use_cache is not None else self.config.use_cache - else: - use_cache = False - - if input_ids is not None and inputs_embeds is not None: - raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time") - elif input_ids is not None: - input_shape = input_ids.size() - batch_size, seq_length = input_shape - elif inputs_embeds is not None: - input_shape = inputs_embeds.size()[:-1] - batch_size, seq_length = input_shape - else: - raise ValueError("You have to specify either input_ids or inputs_embeds") - - device = input_ids.device if input_ids is not None else inputs_embeds.device - - # past_key_values_length - past_key_values_length = ( - past_key_values[0][0].shape[2] if past_key_values is not None else 0 - ) - - if attention_mask is None: - attention_mask = torch.ones( - ((batch_size, seq_length + past_key_values_length)), device=device - ) - if token_type_ids is None: - token_type_ids = torch.zeros(input_shape, dtype=torch.long, device=device) - - # We can provide a self-attention mask of dimensions [batch_size, from_seq_length, to_seq_length] - # ourselves in which case we just need to make it broadcastable to all heads. - extended_attention_mask: torch.Tensor = self.get_extended_attention_mask( - attention_mask, input_shape, device - ) - - # If a 2D or 3D attention mask is provided for the cross-attention - # we need to make broadcastable to [batch_size, num_heads, seq_length, seq_length] - if self.config.is_decoder and encoder_hidden_states is not None: - encoder_batch_size, encoder_sequence_length, _ = encoder_hidden_states.size() - encoder_hidden_shape = (encoder_batch_size, encoder_sequence_length) - if encoder_attention_mask is None: - encoder_attention_mask = torch.ones(encoder_hidden_shape, device=device) - encoder_extended_attention_mask = self.invert_attention_mask(encoder_attention_mask) - else: - encoder_extended_attention_mask = None - # if os.environ.get('IPDB_SHILONG_DEBUG', None) == 'INFO': - # import ipdb; ipdb.set_trace() - - # Prepare head mask if needed - # 1.0 in head_mask indicate we keep the head - # attention_probs has shape bsz x n_heads x N x N - # input head_mask has shape [num_heads] or [num_hidden_layers x num_heads] - # and head_mask is converted to shape [num_hidden_layers x batch x num_heads x seq_length x seq_length] - head_mask = self.get_head_mask(head_mask, self.config.num_hidden_layers) - - embedding_output = self.embeddings( - input_ids=input_ids, - position_ids=position_ids, - token_type_ids=token_type_ids, - inputs_embeds=inputs_embeds, - past_key_values_length=past_key_values_length, - ) - - encoder_outputs = self.encoder( - embedding_output, - attention_mask=extended_attention_mask, - head_mask=head_mask, - encoder_hidden_states=encoder_hidden_states, - encoder_attention_mask=encoder_extended_attention_mask, - past_key_values=past_key_values, - use_cache=use_cache, - output_attentions=output_attentions, - output_hidden_states=output_hidden_states, - return_dict=return_dict, - ) - sequence_output = encoder_outputs[0] - pooled_output = self.pooler(sequence_output) if self.pooler is not None else None - - if not return_dict: - return (sequence_output, pooled_output) + encoder_outputs[1:] - - return BaseModelOutputWithPoolingAndCrossAttentions( - last_hidden_state=sequence_output, - pooler_output=pooled_output, - past_key_values=encoder_outputs.past_key_values, - hidden_states=encoder_outputs.hidden_states, - attentions=encoder_outputs.attentions, - cross_attentions=encoder_outputs.cross_attentions, - ) - - -class TextEncoderShell(nn.Module): - def __init__(self, text_encoder): - super().__init__() - self.text_encoder = text_encoder - self.config = self.text_encoder.config - - def forward(self, **kw): - # feed into text encoder - return self.text_encoder(**kw) - - -def generate_masks_with_special_tokens(tokenized, special_tokens_list, tokenizer): - """Generate attention mask between each pair of special tokens - Args: - input_ids (torch.Tensor): input ids. Shape: [bs, num_token] - special_tokens_mask (list): special tokens mask. - Returns: - torch.Tensor: attention mask between each special tokens. - """ - input_ids = tokenized["input_ids"] - bs, num_token = input_ids.shape - # special_tokens_mask: bs, num_token. 1 for special tokens. 0 for normal tokens - special_tokens_mask = torch.zeros((bs, num_token), device=input_ids.device).bool() - for special_token in special_tokens_list: - special_tokens_mask |= input_ids == special_token - - # idxs: each row is a list of indices of special tokens - idxs = torch.nonzero(special_tokens_mask) - - # generate attention mask and positional ids - attention_mask = ( - torch.eye(num_token, device=input_ids.device).bool().unsqueeze(0).repeat(bs, 1, 1) - ) - position_ids = torch.zeros((bs, num_token), device=input_ids.device) - previous_col = 0 - for i in range(idxs.shape[0]): - row, col = idxs[i] - if (col == 0) or (col == num_token - 1): - attention_mask[row, col, col] = True - position_ids[row, col] = 0 - else: - attention_mask[row, previous_col + 1 : col + 1, previous_col + 1 : col + 1] = True - position_ids[row, previous_col + 1 : col + 1] = torch.arange( - 0, col - previous_col, device=input_ids.device - ) - - previous_col = col - - # # padding mask - # padding_mask = tokenized['attention_mask'] - # attention_mask = attention_mask & padding_mask.unsqueeze(1).bool() & padding_mask.unsqueeze(2).bool() - - return attention_mask, position_ids.to(torch.long) - - -def generate_masks_with_special_tokens_and_transfer_map(tokenized, special_tokens_list, tokenizer): - """Generate attention mask between each pair of special tokens - Args: - input_ids (torch.Tensor): input ids. Shape: [bs, num_token] - special_tokens_mask (list): special tokens mask. - Returns: - torch.Tensor: attention mask between each special tokens. - """ - input_ids = tokenized["input_ids"] - bs, num_token = input_ids.shape - # special_tokens_mask: bs, num_token. 1 for special tokens. 0 for normal tokens - special_tokens_mask = torch.zeros((bs, num_token), device=input_ids.device).bool() - for special_token in special_tokens_list: - special_tokens_mask |= input_ids == special_token - - # idxs: each row is a list of indices of special tokens - idxs = torch.nonzero(special_tokens_mask) - - # generate attention mask and positional ids - attention_mask = ( - torch.eye(num_token, device=input_ids.device).bool().unsqueeze(0).repeat(bs, 1, 1) - ) - position_ids = torch.zeros((bs, num_token), device=input_ids.device) - cate_to_token_mask_list = [[] for _ in range(bs)] - previous_col = 0 - for i in range(idxs.shape[0]): - row, col = idxs[i] - if (col == 0) or (col == num_token - 1): - attention_mask[row, col, col] = True - position_ids[row, col] = 0 - else: - attention_mask[row, previous_col + 1 : col + 1, previous_col + 1 : col + 1] = True - position_ids[row, previous_col + 1 : col + 1] = torch.arange( - 0, col - previous_col, device=input_ids.device - ) - c2t_maski = torch.zeros((num_token), device=input_ids.device).bool() - c2t_maski[previous_col + 1 : col] = True - cate_to_token_mask_list[row].append(c2t_maski) - previous_col = col - - cate_to_token_mask_list = [ - torch.stack(cate_to_token_mask_listi, dim=0) - for cate_to_token_mask_listi in cate_to_token_mask_list - ] - - # # padding mask - # padding_mask = tokenized['attention_mask'] - # attention_mask = attention_mask & padding_mask.unsqueeze(1).bool() & padding_mask.unsqueeze(2).bool() - - return attention_mask, position_ids.to(torch.long), cate_to_token_mask_list - diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/groundingdino.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/groundingdino.py index 14bebd5..75c2eab 100644 --- a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/groundingdino.py +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/groundingdino.py @@ -21,6 +21,9 @@ import torch import torch.nn.functional as F from torch import nn +from simple_syrup.runtime.grounding_dino_bert_adapter import ( + prepare_grounding_dino_bert_attention_mask, +) from simple_syrup.third_party.groundingdino_runtime.util import get_tokenlizer from simple_syrup.third_party.groundingdino_runtime.util.misc import ( NestedTensor, @@ -30,10 +33,7 @@ from simple_syrup.third_party.groundingdino_runtime.util.misc import ( from ..registry import MODULE_BUILD_FUNCS from .backbone import build_backbone -from .bertwarper import ( - BertModelWarper, - generate_masks_with_special_tokens_and_transfer_map, -) +from .text_token_masks import generate_text_token_masks from .transformer import build_transformer from .utils import MLP, ContrastiveEmbed @@ -98,7 +98,6 @@ class GroundingDINO(nn.Module): self.bert = get_tokenlizer.get_pretrained_language_model(text_encoder_type) self.bert.pooler.dense.weight.requires_grad_(False) self.bert.pooler.dense.bias.requires_grad_(False) - self.bert = BertModelWarper(bert_model=self.bert) self.feat_map = nn.Linear(self.bert.config.hidden_size, self.hidden_dim, bias=True) nn.init.constant_(self.feat_map.bias.data, 0) @@ -228,9 +227,7 @@ class GroundingDINO(nn.Module): text_self_attention_masks, position_ids, cate_to_token_mask_list, - ) = generate_masks_with_special_tokens_and_transfer_map( - tokenized, self.specical_tokens, self.tokenizer - ) + ) = generate_text_token_masks(tokenized, self.specical_tokens) if text_self_attention_masks.shape[1] > self.max_text_len: text_self_attention_masks = text_self_attention_masks[ @@ -250,6 +247,12 @@ class GroundingDINO(nn.Module): # import ipdb; ipdb.set_trace() tokenized_for_encoder = tokenized + tokenized_for_encoder["attention_mask"] = ( + prepare_grounding_dino_bert_attention_mask( + tokenized_for_encoder["attention_mask"], + text_encoder=self.bert, + ) + ) bert_output = self.bert(**tokenized_for_encoder) # bs, 195, 768 encoded_text = self.feat_map(bert_output["last_hidden_state"]) # bs, 195, d_model @@ -383,4 +386,3 @@ def build_groundingdino(args): ) return model - diff --git a/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/text_token_masks.py b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/text_token_masks.py new file mode 100644 index 0000000..25b5f66 --- /dev/null +++ b/simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/text_token_masks.py @@ -0,0 +1,64 @@ +# ------------------------------------------------------------------------ +# Grounding DINO +# url: https://github.com/IDEA-Research/GroundingDINO +# Copyright (c) 2023 IDEA. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +"""Build GroundingDINO text-token masks around special-token boundaries.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +import torch + + +def generate_text_token_masks( + tokenized: Mapping[str, torch.Tensor], + special_token_ids: Sequence[int], +) -> tuple[torch.Tensor, torch.Tensor, list[torch.Tensor]]: + """Generate sub-sentence attention, position, and category token masks.""" + + input_ids = tokenized["input_ids"] + batch_size, token_count = input_ids.shape + special_tokens_mask = torch.zeros_like(input_ids, dtype=torch.bool) + for special_token_id in special_token_ids: + special_tokens_mask |= input_ids == special_token_id + + special_token_positions = torch.nonzero(special_tokens_mask) + attention_mask = ( + torch.eye(token_count, device=input_ids.device, dtype=torch.bool) + .unsqueeze(0) + .repeat(batch_size, 1, 1) + ) + position_ids = torch.zeros( + (batch_size, token_count), device=input_ids.device, dtype=torch.long + ) + category_masks: list[list[torch.Tensor]] = [[] for _ in range(batch_size)] + previous_column = 0 + + for row, column in special_token_positions: + if column == 0 or column == token_count - 1: + attention_mask[row, column, column] = True + position_ids[row, column] = 0 + else: + segment = slice(previous_column + 1, column + 1) + attention_mask[row, segment, segment] = True + position_ids[row, segment] = torch.arange( + 0, + column - previous_column, + device=input_ids.device, + ) + category_mask = torch.zeros( + token_count, device=input_ids.device, dtype=torch.bool + ) + category_mask[previous_column + 1 : column] = True + category_masks[row].append(category_mask) + previous_column = int(column) + + stacked_category_masks = [ + torch.stack(batch_category_masks, dim=0) + for batch_category_masks in category_masks + ] + return attention_mask, position_ids, stacked_category_masks diff --git a/tests/test_grounding_dino_bert_adapter.py b/tests/test_grounding_dino_bert_adapter.py new file mode 100644 index 0000000..de5ea6f --- /dev/null +++ b/tests/test_grounding_dino_bert_adapter.py @@ -0,0 +1,163 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Test GroundingDINO adaptation to supported Transformers BERT APIs.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import cast + +import pytest +import torch +import transformers +from torch import nn +from transformers import BertConfig, BertModel + +from simple_syrup.runtime.grounding_dino_bert_adapter import ( + prepare_grounding_dino_bert_attention_mask, +) + + +@pytest.mark.parametrize("transformers_version", ["4.50.3", "4.57.6"]) +def test_transformers_v4_preserves_grounding_dino_attention_mask( + transformers_version: str, +) -> None: + """Let supported v4 BERT normalize its native three-dimensional mask.""" + + text_encoder = nn.Linear(2, 2) + attention_mask = _block_attention_mask() + + prepared = prepare_grounding_dino_bert_attention_mask( + attention_mask, + text_encoder=text_encoder, + transformers_version=transformers_version, + ) + + assert prepared is attention_mask + + +@pytest.mark.parametrize("transformers_version", ["5.0.0", "5.13.1"]) +def test_transformers_v5_receives_prepared_additive_attention_mask( + transformers_version: str, +) -> None: + """Give v5 BERT the four-dimensional additive mask its public API accepts.""" + + text_encoder = nn.Linear(2, 2, dtype=torch.float16) + attention_mask = _block_attention_mask() + + prepared = prepare_grounding_dino_bert_attention_mask( + attention_mask, + text_encoder=text_encoder, + transformers_version=transformers_version, + ) + + assert prepared.shape == (1, 1, 4, 4) + assert prepared.dtype is torch.float16 + assert prepared.device == attention_mask.device + assert torch.equal( + prepared[0, 0].eq(0), + attention_mask[0], + ) + assert prepared[0, 0, 0, 2] == torch.finfo(torch.float16).min + + +@pytest.mark.parametrize("transformers_version", ["4.50.3", "5.13.1"]) +def test_padding_attention_mask_passes_through_for_supported_versions( + transformers_version: str, +) -> None: + """Leave ordinary tokenizer padding masks to native BERT handling.""" + + text_encoder = nn.Linear(2, 2) + attention_mask = torch.tensor([[1, 1, 0]], dtype=torch.long) + + prepared = prepare_grounding_dino_bert_attention_mask( + attention_mask, + text_encoder=text_encoder, + transformers_version=transformers_version, + ) + + assert prepared is attention_mask + + +@pytest.mark.parametrize( + "transformers_version", + ["4.50.2", "6.0.0", "not-a-version"], +) +def test_unsupported_transformers_versions_fail_with_actionable_error( + transformers_version: str, +) -> None: + """Reject unverified API generations before BERT fails inside inference.""" + + with pytest.raises(RuntimeError, match=r"Transformers >=4\.50\.3,<6"): + prepare_grounding_dino_bert_attention_mask( + _block_attention_mask(), + text_encoder=nn.Linear(2, 2), + transformers_version=transformers_version, + ) + + +def test_sub_sentence_attention_mask_must_be_boolean_and_square() -> None: + """Reject malformed GroundingDINO block masks at their owning boundary.""" + + text_encoder = nn.Linear(2, 2) + + with pytest.raises(ValueError, match="boolean"): + prepare_grounding_dino_bert_attention_mask( + torch.ones((1, 4, 4)), + text_encoder=text_encoder, + transformers_version="5.13.1", + ) + with pytest.raises(ValueError, match="square"): + prepare_grounding_dino_bert_attention_mask( + torch.ones((1, 4, 3), dtype=torch.bool), + text_encoder=text_encoder, + transformers_version="5.13.1", + ) + + +def test_installed_transformers_runs_native_bert_with_adapted_block_mask() -> None: + """Exercise the installed v4 or v5 BERT implementation through the adapter.""" + + config = BertConfig( + vocab_size=32, + hidden_size=16, + num_hidden_layers=1, + num_attention_heads=2, + intermediate_size=32, + ) + bert_model_factory = cast(Callable[[BertConfig], nn.Module], BertModel) + text_encoder = bert_model_factory(config).eval() + input_ids = torch.tensor([[1, 2, 3, 4]], dtype=torch.long) + attention_mask = prepare_grounding_dino_bert_attention_mask( + _block_attention_mask(), + text_encoder=text_encoder, + transformers_version=transformers.__version__, + ) + + with torch.inference_mode(): + output = text_encoder( + input_ids=input_ids, + attention_mask=attention_mask, + position_ids=torch.tensor([[0, 1, 0, 1]], dtype=torch.long), + return_dict=True, + ) + + assert output.last_hidden_state.shape == (1, 4, 16) + assert torch.isfinite(output.last_hidden_state).all() + + +def _block_attention_mask() -> torch.Tensor: + """Return two isolated two-token attention blocks.""" + + return torch.tensor( + [ + [ + [True, True, False, False], + [True, True, False, False], + [False, False, True, True], + [False, False, True, True], + ] + ] + ) diff --git a/tests/test_grounding_dino_text_token_masks.py b/tests/test_grounding_dino_text_token_masks.py new file mode 100644 index 0000000..6c6f863 --- /dev/null +++ b/tests/test_grounding_dino_text_token_masks.py @@ -0,0 +1,83 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Characterize GroundingDINO text-token attention mask behavior.""" + +from __future__ import annotations + +import importlib.util +from collections.abc import Callable +from pathlib import Path +from types import ModuleType +from typing import cast + +import torch + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEXT_TOKEN_MASKS_PATH = ( + REPO_ROOT + / "simple_syrup" + / "third_party" + / "groundingdino_runtime" + / "models" + / "GroundingDINO" + / "text_token_masks.py" +) +MaskGenerator = Callable[ + [dict[str, torch.Tensor], list[int]], + tuple[torch.Tensor, torch.Tensor, list[torch.Tensor]], +] + + +def _load_module(path: Path) -> ModuleType: + """Load one vendored module without importing the full model package.""" + + spec = importlib.util.spec_from_file_location( + "grounding_dino_text_token_masks_test", path + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load module spec for {path}.") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +generate_text_token_masks = cast( + MaskGenerator, + _load_module(TEXT_TOKEN_MASKS_PATH).generate_text_token_masks, +) + + +def test_text_token_masks_preserve_special_token_delimited_attention_blocks() -> None: + """Keep tokens isolated within the sub-sentence ending at each delimiter.""" + + tokenized = {"input_ids": torch.tensor([[101, 2001, 1012, 102]], dtype=torch.long)} + + attention_mask, position_ids, category_masks = generate_text_token_masks( + tokenized, + [101, 102, 1012, 1029], + ) + + expected_attention_mask = torch.tensor( + [ + [ + [True, False, False, False], + [False, True, True, False], + [False, True, True, False], + [False, False, False, True], + ] + ] + ) + expected_category_masks = torch.tensor( + [ + [ + [False, True, False, False], + ] + ] + ) + + assert attention_mask.dtype is torch.bool + assert torch.equal(attention_mask, expected_attention_mask) + assert torch.equal(position_ids, torch.tensor([[0, 0, 1, 0]])) + assert torch.equal(torch.stack(category_masks), expected_category_masks) diff --git a/tests/test_phase_progress.py b/tests/test_phase_progress.py index f5654bf..cd537d2 100644 --- a/tests/test_phase_progress.py +++ b/tests/test_phase_progress.py @@ -1,3 +1,7 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + """Tests for standard Comfy phase-progress publication.""" from __future__ import annotations diff --git a/third_party/manifest.toml b/third_party/manifest.toml index 2ec5b95..af4148b 100644 --- a/third_party/manifest.toml +++ b/third_party/manifest.toml @@ -39,10 +39,10 @@ vendored_files = [ "simple_syrup/third_party/groundingdino_runtime/models/__init__.py", "simple_syrup/third_party/groundingdino_runtime/models/registry.py", "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/__init__.py", - "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/bertwarper.py", "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/fuse_modules.py", "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/groundingdino.py", "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/ms_deform_attn.py", + "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/text_token_masks.py", "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer_vanilla.py", "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/transformer.py", "simple_syrup/third_party/groundingdino_runtime/models/GroundingDINO/utils.py", From 4afd112399c9987d41523ce0c388b3978016d337 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Mon, 13 Jul 2026 21:05:44 -0400 Subject: [PATCH 20/40] ci(release): install host dependency for compatibility tests --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94edd7e..ea31907 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: - name: Install compatibility test dependencies run: >- - python -m pip install pytest torch + python -m pip install aiohttp pytest torch "transformers${{ matrix.version }}" - name: Verify GroundingDINO BERT compatibility From 009e8b4821d58d1a872b52a138f48dfbf229e866 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Mon, 13 Jul 2026 21:09:25 -0400 Subject: [PATCH 21/40] ci(release): isolate transformers compatibility tests --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea31907..b0f6fc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,12 +46,14 @@ jobs: - name: Install compatibility test dependencies run: >- - python -m pip install aiohttp pytest torch + python -m pip install pytest torch "transformers${{ matrix.version }}" - name: Verify GroundingDINO BERT compatibility run: >- python -m pytest -q + --rootdir=tests + --confcutdir=tests tests/test_grounding_dino_bert_adapter.py tests/test_grounding_dino_text_token_masks.py From 74f68ba05a12bae59a2a5dd45a7a7bd28d302df8 Mon Sep 17 00:00:00 2001 From: Daisy Date: Tue, 14 Jul 2026 01:14:17 +0000 Subject: [PATCH 22/40] 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)) --- CHANGELOG.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- pyproject.toml | 2 +- simple_syrup/__init__.py | 2 +- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bbb76a..609941d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# [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)) + # [1.4.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.3.0...v1.4.0) (2026-06-02) diff --git a/package-lock.json b/package-lock.json index b0315f2..af4fc02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-syrup-comfyui", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-syrup-comfyui", - "version": "1.4.0", + "version": "1.5.0", "license": "AGPL-3.0-or-later", "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/package.json b/package.json index eb67bbb..f89952b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-syrup-comfyui", - "version": "1.4.0", + "version": "1.5.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index d19e7fb..6527fa4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "SimpleSyrup" description = "Workflow-focused ComfyUI extensions for image generation." -version = "1.4.0" +version = "1.5.0" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] requires-python = ">=3.11" diff --git a/simple_syrup/__init__.py b/simple_syrup/__init__.py index 6fe1aa9..2ad1832 100644 --- a/simple_syrup/__init__.py +++ b/simple_syrup/__init__.py @@ -6,6 +6,6 @@ from __future__ import annotations -__version__ = "1.4.0" +__version__ = "1.5.0" __all__: list[str] = ["__version__"] From 250ee42ea1a46811c7518689737255cd9d3191eb Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Mon, 20 Jul 2026 20:54:39 -0400 Subject: [PATCH 23/40] feat(conditioning): add regional prompting and SEP-local LoRAs --- __init__.py | 4 + simple_syrup/domain/prompt_control_prompt.py | 13 +- simple_syrup/domain/regional_prompting.py | 74 +++ simple_syrup/masking/regional_prompt_masks.py | 61 +++ simple_syrup/nodes/encode_prompt_batch.py | 12 +- simple_syrup/nodes/ksampler_extras.py | 159 +----- ..._and_encode_prompts_with_prompt_control.py | 14 +- simple_syrup/nodes_v3/__init__.py | 8 + .../nodes_v3/compose_regional_conditioning.py | 83 +++ ...encode_prompt_batch_with_prompt_control.py | 10 +- .../nodes_v3/ksampler_prompt_by_region.py | 103 ++++ .../ksampler_prompt_by_tiled_region.py | 123 +++++ simple_syrup/nodes_v3/load_mask_batch.py | 110 ++++ .../nodes_v3/regional_ksampler_schema.py | 169 ++++++ ..._and_encode_prompts_with_prompt_control.py | 16 +- .../runtime/mask_batch_preview_routes.py | 185 +++++++ simple_syrup/runtime/mask_file_loader.py | 91 ++++ .../runtime/mixture_of_diffusers_sampling.py | 13 +- .../runtime/multidiffusion_sampling.py | 13 +- .../runtime/prompt_control_batch_graph.py | 166 +++--- .../runtime/prompt_control_graph_adapter.py | 187 +++++++ .../prompt_control_schedule_encode_graph.py | 250 ++++----- simple_syrup/runtime/tiled_sampling.py | 36 +- .../services/ksampler_sampling_service.py | 186 +++++++ .../services/load_mask_batch_service.py | 97 ++++ ...prompt_control_segment_planning_service.py | 73 +++ .../services/regional_conditioning_service.py | 163 ++++++ .../tiled_diffusion_sampling_service.py | 6 + ...t_compose_regional_conditioning_v3_node.py | 79 +++ tests/test_load_mask_batch_service.py | 148 ++++++ tests/test_load_mask_batch_v3_node.py | 110 ++++ tests/test_mask_batch_preview_routes.py | 353 +++++++++++++ tests/test_mask_file_loader.py | 127 +++++ ...est_native_hooked_regional_conditioning.py | 195 +++++++ tests/test_node_tooltips.py | 22 + tests/test_prompt_control_batch_graph.py | 41 +- tests/test_prompt_control_prompt.py | 6 +- ...st_prompt_control_schedule_encode_graph.py | 33 +- ...prompt_control_segment_planning_service.py | 69 +++ tests/test_regional_conditioning_service.py | 222 ++++++++ tests/test_regional_ksampler_v3_nodes.py | 194 +++++++ tests/test_regional_prompt_masks.py | 49 ++ tests/test_regional_prompt_workflow.py | 142 +++++ tests/test_regional_prompting_domain.py | 94 ++++ tests/test_registration.py | 10 +- .../test_tiled_diffusion_sampling_service.py | 1 + tests/test_tiled_sampling_runtime.py | 19 + web/dist/simple-syrup.js | 387 ++++++++++++++ web/src/api.ts | 63 +++ web/src/main.ts | 11 +- web/src/maskBatchPreview.ts | 73 +++ web/src/maskBatchUpload.ts | 486 +++++++++++++++++ web/src/types.ts | 18 +- web/tests/api.test.ts | 58 ++ web/tests/maskBatchPreview.test.ts | 157 ++++++ web/tests/maskBatchUpload.test.ts | 500 ++++++++++++++++++ 56 files changed, 5636 insertions(+), 456 deletions(-) create mode 100644 simple_syrup/domain/regional_prompting.py create mode 100644 simple_syrup/masking/regional_prompt_masks.py create mode 100644 simple_syrup/nodes_v3/compose_regional_conditioning.py create mode 100644 simple_syrup/nodes_v3/ksampler_prompt_by_region.py create mode 100644 simple_syrup/nodes_v3/ksampler_prompt_by_tiled_region.py create mode 100644 simple_syrup/nodes_v3/load_mask_batch.py create mode 100644 simple_syrup/nodes_v3/regional_ksampler_schema.py create mode 100644 simple_syrup/runtime/mask_batch_preview_routes.py create mode 100644 simple_syrup/runtime/mask_file_loader.py create mode 100644 simple_syrup/runtime/prompt_control_graph_adapter.py create mode 100644 simple_syrup/services/ksampler_sampling_service.py create mode 100644 simple_syrup/services/load_mask_batch_service.py create mode 100644 simple_syrup/services/prompt_control_segment_planning_service.py create mode 100644 simple_syrup/services/regional_conditioning_service.py create mode 100644 tests/test_compose_regional_conditioning_v3_node.py create mode 100644 tests/test_load_mask_batch_service.py create mode 100644 tests/test_load_mask_batch_v3_node.py create mode 100644 tests/test_mask_batch_preview_routes.py create mode 100644 tests/test_mask_file_loader.py create mode 100644 tests/test_native_hooked_regional_conditioning.py create mode 100644 tests/test_prompt_control_segment_planning_service.py create mode 100644 tests/test_regional_conditioning_service.py create mode 100644 tests/test_regional_ksampler_v3_nodes.py create mode 100644 tests/test_regional_prompt_masks.py create mode 100644 tests/test_regional_prompt_workflow.py create mode 100644 tests/test_regional_prompting_domain.py create mode 100644 web/src/maskBatchPreview.ts create mode 100644 web/src/maskBatchUpload.ts create mode 100644 web/tests/maskBatchPreview.test.ts create mode 100644 web/tests/maskBatchUpload.test.ts diff --git a/__init__.py b/__init__.py index a25cbf2..84358d3 100644 --- a/__init__.py +++ b/__init__.py @@ -15,6 +15,9 @@ sys.modules.setdefault("simple_syrup", _simple_syrup_package) from .simple_syrup.runtime.external_llm_routes import ( # noqa: E402 register_external_llm_routes, ) +from .simple_syrup.runtime.mask_batch_preview_routes import ( # noqa: E402 + register_mask_batch_preview_routes, +) from .simple_syrup.runtime.settings_routes import register_settings_routes # noqa: E402 WEB_DIRECTORY = "./web/dist" @@ -40,6 +43,7 @@ async def comfy_entrypoint() -> object: register_settings_routes() register_external_llm_routes() +register_mask_batch_preview_routes() __all__ = [ "WEB_DIRECTORY", diff --git a/simple_syrup/domain/prompt_control_prompt.py b/simple_syrup/domain/prompt_control_prompt.py index a1bbe4a..eaf3311 100644 --- a/simple_syrup/domain/prompt_control_prompt.py +++ b/simple_syrup/domain/prompt_control_prompt.py @@ -25,9 +25,15 @@ class PreparedPromptChunk: @dataclass(frozen=True) class PreparedPromptSide: - """Store ordered prompt chunks and all scheduling tags for one prompt side.""" + """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 @@ -44,7 +50,7 @@ def extract_lora_tags(text: str) -> str: def prepare_prompt_side(text: str, separator: str) -> PreparedPromptSide: - """Split a prompt side into cleaned chunks and aggregate LoRA tags.""" + """Split a prompt side into ordered cleaned chunks with local LoRA tags.""" chunks = tuple( PreparedPromptChunk( @@ -53,8 +59,7 @@ def prepare_prompt_side(text: str, separator: str) -> PreparedPromptSide: ) for chunk in split_prompt_batch(text, separator) ) - lora_tags = "\n".join(chunk.lora_tags for chunk in chunks if chunk.lora_tags) - return PreparedPromptSide(chunks=chunks, lora_tags=lora_tags) + return PreparedPromptSide(chunks=chunks) def apply_encode_style(encode_style: str, prompt_text: str) -> str: diff --git a/simple_syrup/domain/regional_prompting.py b/simple_syrup/domain/regional_prompting.py new file mode 100644 index 0000000..2c4f90f --- /dev/null +++ b/simple_syrup/domain/regional_prompting.py @@ -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) + ), + ) diff --git a/simple_syrup/masking/regional_prompt_masks.py b/simple_syrup/masking/regional_prompt_masks.py new file mode 100644 index 0000000..aee70eb --- /dev/null +++ b/simple_syrup/masking/regional_prompt_masks.py @@ -0,0 +1,61 @@ +# 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 + +from collections.abc import Sequence + +import torch + +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.""" + + 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) + if feather == 0: + return normalized + return gaussian_feather_mask(normalized, feather) + + +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] + + +def complementary_global_prompt_mask( + mask_batch: torch.Tensor, + mask_indices: Sequence[int], + regional_prompt_weight: float, +) -> torch.Tensor: + """Return global influence that recedes across accumulated region coverage.""" + + if not mask_indices: + raise ValueError("global prompt masking requires at least one regional mask.") + coverage = torch.zeros_like(mask_batch[0:1]) + for index in mask_indices: + coverage.add_(regional_mask(mask_batch, index)) + coverage.clamp_(0.0, 1.0) + return 1.0 - coverage * regional_prompt_weight diff --git a/simple_syrup/nodes/encode_prompt_batch.py b/simple_syrup/nodes/encode_prompt_batch.py index 6f54511..4702e3c 100644 --- a/simple_syrup/nodes/encode_prompt_batch.py +++ b/simple_syrup/nodes/encode_prompt_batch.py @@ -18,14 +18,12 @@ 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." - ) + DESCRIPTION = "Encodes [SEP]-separated prompts into ordered conditioning batches." SEARCH_ALIASES = ["conditioning batch", "prompt batch", "segs prompts"] encoder_class: ClassVar[type[ComfyConditioningEncoder]] = ComfyConditioningEncoder @@ -51,7 +49,7 @@ class EncodePromptBatch: "default": "", "multiline": True, "tooltip": ( - "Positive prompts in SEGS order, separated by [SEP]." + "Ordered positive prompt entries separated by [SEP]." ), }, ), @@ -61,7 +59,7 @@ class EncodePromptBatch: "default": "", "multiline": True, "tooltip": ( - "Negative prompts in SEGS order, separated by [SEP]." + "Ordered negative prompt entries separated by [SEP]." ), }, ), diff --git a/simple_syrup/nodes/ksampler_extras.py b/simple_syrup/nodes/ksampler_extras.py index b44b00e..b78d8c5 100644 --- a/simple_syrup/nodes/ksampler_extras.py +++ b/simple_syrup/nodes/ksampler_extras.py @@ -6,13 +6,10 @@ from __future__ import annotations -from importlib import import_module -from typing import Any +from typing import Any, ClassVar -import torch - -from ..domain.conditioning_batch import ConditioningBatch, select_conditioning from ..runtime import sampling_samplers, sampling_schedulers +from ..services.ksampler_sampling_service import KSamplerSamplingService from . import tooltips Latent = dict[str, Any] @@ -30,6 +27,7 @@ class KSamplerExtras: "compatible workflows." ) SEARCH_ALIASES = ["ksampler", "sampler", "ays", "gits", "lcm"] + service_class: ClassVar[type[KSamplerSamplingService]] = KSamplerSamplingService @classmethod def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: @@ -113,151 +111,16 @@ class KSamplerExtras: ) -> tuple[Latent]: """Sample a latent with ComfyUI samplers and extra scheduler sigmas.""" - sampler = sampling_samplers.resolve_sampler(sampler_name) - sigmas = sampling_schedulers.calculate_sigmas( + output = self.service_class().sample( model=model, - scheduler_name=scheduler, - sampler_name=sampler_name, + seed=seed, steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, denoise=denoise, - ).to(model.load_device) - - latent_samples = latent_image["samples"] - comfy_sample = _comfy_sample() - comfy_utils = _comfy_utils() - latent_preview = _latent_preview() - - latent_samples = comfy_sample.fix_empty_latent_channels( - model, - latent_samples, - latent_image.get("downscale_ratio_spacial", None), ) - - batch_inds = ( - latent_image["batch_index"] if "batch_index" in latent_image else None - ) - noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds) - noise_mask = latent_image.get("noise_mask", None) - - callback = latent_preview.prepare_callback(model, steps) - disable_pbar = not comfy_utils.PROGRESS_BAR_ENABLED - if _uses_conditioning_batch(positive, negative): - samples = _sample_conditioning_batch( - comfy_sample=comfy_sample, - model=model, - noise=noise, - cfg=cfg, - sampler=sampler, - sigmas=sigmas, - positive=positive, - negative=negative, - latent_samples=latent_samples, - noise_mask=noise_mask, - callback=callback, - disable_pbar=disable_pbar, - seed=seed, - ) - else: - samples = comfy_sample.sample_custom( - model, - noise, - cfg, - sampler, - sigmas, - positive, - negative, - latent_samples, - noise_mask=noise_mask, - callback=callback, - disable_pbar=disable_pbar, - seed=seed, - ) - - output = latent_image.copy() - output.pop("downscale_ratio_spacial", None) - output["samples"] = samples return (output,) - - -def _comfy_sample() -> Any: - """Import ComfyUI sample helpers lazily.""" - - import comfy.sample - - return comfy.sample - - -def _uses_conditioning_batch(positive: Any, negative: Any) -> bool: - """Return whether either conditioning input needs per-item selection.""" - - return isinstance(positive, ConditioningBatch) or isinstance( - negative, - ConditioningBatch, - ) - - -def _sample_conditioning_batch( - *, - comfy_sample: Any, - model: Any, - noise: torch.Tensor, - cfg: float, - sampler: Any, - sigmas: torch.Tensor, - positive: Any, - negative: Any, - latent_samples: torch.Tensor, - noise_mask: Any, - callback: Any, - disable_pbar: bool, - seed: int, -) -> torch.Tensor: - """Sample each latent batch item with its selected conditioning.""" - - sampled: list[torch.Tensor] = [] - for index in range(int(latent_samples.shape[0])): - sampled.append( - comfy_sample.sample_custom( - model, - noise[index : index + 1], - cfg, - sampler, - sigmas, - select_conditioning(positive, index), - select_conditioning(negative, index), - latent_samples[index : index + 1], - noise_mask=_slice_noise_mask(noise_mask, index, latent_samples), - callback=callback, - disable_pbar=disable_pbar, - seed=seed, - ) - ) - return torch.cat(sampled, dim=0) - - -def _slice_noise_mask( - noise_mask: Any, - index: int, - latent_samples: torch.Tensor, -) -> Any: - """Return the noise mask slice matching one latent batch item.""" - - if isinstance(noise_mask, torch.Tensor) and noise_mask.shape[0] == int( - latent_samples.shape[0], - ): - return noise_mask[index : index + 1] - return noise_mask - - -def _comfy_utils() -> Any: - """Import ComfyUI utility state lazily.""" - - import comfy.utils - - return comfy.utils - - -def _latent_preview() -> Any: - """Import ComfyUI preview helpers lazily.""" - - return import_module("latent_preview") diff --git a/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py b/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py index 22cd362..c9c18f4 100644 --- a/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py +++ b/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py @@ -23,15 +23,15 @@ class ScheduleAndEncodePromptsWithPromptControl: ) RETURN_NAMES = ("model", "positive", "negative") OUTPUT_TOOLTIPS = ( - "Model after LoRA tags from positive and negative prompts are scheduled.", + "Model with single-prompt LoRAs applied; SEP-local LoRAs stay on conditioning.", "Positive conditioning or SimpleSyrup conditioning batch.", "Negative conditioning or SimpleSyrup conditioning batch.", ) FUNCTION = "execute" CATEGORY = "SimpleSyrup/Conditioning" DESCRIPTION = ( - "Schedules Prompt-Control LoRAs and encodes prompts, using [SEP] to " - "create SimpleSyrup conditioning batches." + "Schedules Prompt-Control LoRAs and encodes prompts. [SEP] creates " + "conditioning batches with segment-local LoRA hooks." ) SEARCH_ALIASES = ["prompt control", "schedule prompts", "encode prompts"] @@ -67,8 +67,8 @@ class ScheduleAndEncodePromptsWithPromptControl: "default": "", "multiline": False, "tooltip": ( - "Positive Prompt-Control text. [SEP] creates a " - "conditioning batch for SimpleSyrup batch-aware nodes." + "Positive Prompt-Control text; [SEP] creates ordered " + "entries with segment-local LoRA hooks." ), }, ), @@ -78,8 +78,8 @@ class ScheduleAndEncodePromptsWithPromptControl: "default": "", "multiline": False, "tooltip": ( - "Negative Prompt-Control text. [SEP] creates a " - "conditioning batch for SimpleSyrup batch-aware nodes." + "Negative Prompt-Control text; [SEP] creates ordered " + "entries sharing each index's LoRA hooks." ), }, ), diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index 940afed..aa2de4a 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -14,7 +14,10 @@ def get_nodes() -> list[type[object]]: from .batch_region_conditioning import BatchRegionConditioningV3 from .batch_segs import BatchSEGSV3 + from .compose_regional_conditioning import ComposeRegionalConditioningV3 from .external_llm_prompt import ExternalLLMPromptV3 + from .ksampler_prompt_by_region import KSamplerPromptByRegionV3 + from .ksampler_prompt_by_tiled_region import KSamplerPromptByTiledRegionV3 from .legacy_node_wrappers import ( ConditioningBatchAppendV3, ConditioningBatchStartV3, @@ -41,6 +44,7 @@ def get_nodes() -> list[type[object]]: UpscaleLatentFromImageV3, ViTMatteModelLoaderV3, ) + from .load_mask_batch import LoadMaskBatchV3 from .mask_to_segs import MaskToSEGSV3 from .scale_factor import ScaleFactorV3 from .simple_load_checkpoint import SimpleLoadCheckpointV3 @@ -56,6 +60,7 @@ def get_nodes() -> list[type[object]]: BatchSEGSV3, ConditioningBatchAppendV3, ConditioningBatchStartV3, + ComposeRegionalConditioningV3, DetailSEGSAsRegionsV3, DetailSEGSByScaleFactorTiledDiffusionV3, DetailSEGSByScaleFactorV3, @@ -65,10 +70,13 @@ def get_nodes() -> list[type[object]]: GroundedSAMModelInfoV3, GroundingDINOModelLoaderV3, KSamplerExtrasV3, + KSamplerPromptByRegionV3, + KSamplerPromptByTiledRegionV3, KSamplerTiledDiffusionV3, LatentDiagnosticsV3, LayerStyleSAMModelsAdapterV3, LoadUltralyticsModelV3, + LoadMaskBatchV3, MaskToSEGSV3, PromptEncodeStyleAndNormalizationV3, PromptEncodeStyleV3, diff --git a/simple_syrup/nodes_v3/compose_regional_conditioning.py b/simple_syrup/nodes_v3/compose_regional_conditioning.py new file mode 100644 index 0000000..8858208 --- /dev/null +++ b/simple_syrup/nodes_v3/compose_regional_conditioning.py @@ -0,0 +1,83 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node for composing standard masked regional conditioning.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..services.regional_conditioning_service import RegionalConditioningService +from .regional_ksampler_schema import regional_conditioning_inputs + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class ComposeRegionalConditioningV3(_ComfyNodeBase): + """Compose global-first batches and ordered masks for native samplers.""" + + conditioning_service_class: ClassVar[type[RegionalConditioningService]] = ( + RegionalConditioningService + ) + + @classmethod + def define_schema(cls) -> Any: + """Declare the standard regional-conditioning composition schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.ComposeRegionalConditioning", + display_name="Compose Regional Conditioning", + category="SimpleSyrup/Conditioning", + description=( + "Pairs global-first prompt batches with ordered masks and returns " + "standard masked conditioning for native Comfy samplers." + ), + search_aliases=["regional prompt", "masked conditioning"], + inputs=regional_conditioning_inputs(_comfy_io), + outputs=[ + _comfy_io.Conditioning.Output( + "positive", + tooltip=( + "Standard masked positive conditioning with hooks preserved." + ), + ), + _comfy_io.Conditioning.Output( + "negative", + tooltip=( + "Standard masked negative conditioning with hooks preserved." + ), + ), + ], + ) + + @classmethod + def execute( + cls, + positive: object, + negative: object, + region_masks: object, + regional_prompt_weight: float, + region_mask_feather: int, + ) -> tuple[object, object]: + """Return hook-preserving standard Comfy conditioning values.""" + + return cls.conditioning_service_class().assemble( + positive=positive, + negative=negative, + masks=region_masks, + regional_prompt_weight=regional_prompt_weight, + region_mask_feather=region_mask_feather, + ) diff --git a/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py b/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py index cc6a68e..b009edf 100644 --- a/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py +++ b/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py @@ -48,7 +48,7 @@ class EncodePromptBatchWithPromptControl(_ComfyNodeBase): category="SimpleSyrup/Conditioning", description=( "Encodes [SEP]-separated prompts into per-segment Prompt Control " - "conditioning batches." + "conditioning batches with segment-local LoRA hooks." ), inputs=[ _comfy_io.Clip.Input( @@ -64,8 +64,8 @@ class EncodePromptBatchWithPromptControl(_ComfyNodeBase): multiline=True, default="", tooltip=( - "Positive Prompt Control prompts in SEGS order, separated " - "by the separator text." + "Positive Prompt Control prompts in positional order; each " + "segment keeps its aligned LoRA hooks." ), ), _comfy_io.String.Input( @@ -73,8 +73,8 @@ class EncodePromptBatchWithPromptControl(_ComfyNodeBase): multiline=True, default="", tooltip=( - "Negative Prompt Control prompts in SEGS order, separated " - "by the separator text." + "Negative Prompt Control prompts in positional order; each " + "segment shares hooks with the matching positive index." ), ), _comfy_io.String.Input( diff --git a/simple_syrup/nodes_v3/ksampler_prompt_by_region.py b/simple_syrup/nodes_v3/ksampler_prompt_by_region.py new file mode 100644 index 0000000..7a8f8e4 --- /dev/null +++ b/simple_syrup/nodes_v3/ksampler_prompt_by_region.py @@ -0,0 +1,103 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 KSampler for full-context regional prompting.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes import tooltips +from ..services.ksampler_sampling_service import KSamplerSamplingService +from ..services.regional_conditioning_service import RegionalConditioningService +from .regional_ksampler_schema import regional_ksampler_inputs + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class KSamplerPromptByRegionV3(_ComfyNodeBase): + """Sample a full latent with global-first ordered regional prompts.""" + + conditioning_service_class: ClassVar[type[RegionalConditioningService]] = ( + RegionalConditioningService + ) + sampling_service_class: ClassVar[type[KSamplerSamplingService]] = ( + KSamplerSamplingService + ) + + @classmethod + def define_schema(cls) -> Any: + """Declare the non-tiled regional KSampler schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.KSamplerPromptByRegion", + display_name="KSampler (Prompt by Region)", + category="SimpleSyrup/Sampling", + description=( + "Denoises the full latent with one global prompt and ordered " + "mask-bound regional prompts." + ), + search_aliases=["ksampler", "regional prompt", "masked prompt"], + inputs=regional_ksampler_inputs(_comfy_io), + outputs=[ + _comfy_io.Latent.Output( + "latent", + tooltip=tooltips.DENOISED_LATENT_OUTPUT, + ) + ], + ) + + @classmethod + def execute( + cls, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: object, + negative: object, + region_masks: object, + regional_prompt_weight: float, + region_mask_feather: int, + latent_image: dict[str, Any], + denoise: float, + ) -> tuple[dict[str, Any]]: + """Assemble regional conditioning and sample the full latent.""" + + assembled_positive, assembled_negative = ( + cls.conditioning_service_class().assemble( + positive=positive, + negative=negative, + masks=region_masks, + regional_prompt_weight=regional_prompt_weight, + region_mask_feather=region_mask_feather, + ) + ) + output = cls.sampling_service_class().sample( + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=assembled_positive, + negative=assembled_negative, + latent_image=latent_image, + denoise=denoise, + ) + return (output,) diff --git a/simple_syrup/nodes_v3/ksampler_prompt_by_tiled_region.py b/simple_syrup/nodes_v3/ksampler_prompt_by_tiled_region.py new file mode 100644 index 0000000..cc31294 --- /dev/null +++ b/simple_syrup/nodes_v3/ksampler_prompt_by_tiled_region.py @@ -0,0 +1,123 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 tiled KSampler for full-context regional prompting.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes import tooltips +from ..services.regional_conditioning_service import RegionalConditioningService +from ..services.tiled_diffusion_sampling_service import TiledDiffusionSamplingService +from .regional_ksampler_schema import regional_ksampler_inputs, tiled_regional_inputs + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class KSamplerPromptByTiledRegionV3(_ComfyNodeBase): + """Sample tiled latents with global-first ordered regional prompts.""" + + conditioning_service_class: ClassVar[type[RegionalConditioningService]] = ( + RegionalConditioningService + ) + sampling_service_class: ClassVar[type[TiledDiffusionSamplingService]] = ( + TiledDiffusionSamplingService + ) + + @classmethod + def define_schema(cls) -> Any: + """Declare the tiled regional KSampler schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.KSamplerPromptByTiledRegion", + display_name="KSampler (Prompt by Tiled Region)", + category="SimpleSyrup/Sampling", + description=( + "Denoises large latents in overlapping tiles while preserving " + "global and ordered mask-bound regional prompts." + ), + search_aliases=[ + "ksampler", + "regional prompt", + "tiled regional prompt", + "regional hires fix", + ], + inputs=[ + *regional_ksampler_inputs(_comfy_io), + *tiled_regional_inputs(_comfy_io), + ], + outputs=[ + _comfy_io.Latent.Output( + "latent", + tooltip=tooltips.DENOISED_LATENT_OUTPUT, + ) + ], + ) + + @classmethod + def execute( + cls, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: object, + negative: object, + region_masks: object, + regional_prompt_weight: float, + region_mask_feather: int, + latent_image: dict[str, Any], + denoise: float, + diffusion_mode: str, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + ) -> tuple[dict[str, Any]]: + """Assemble regional conditioning and sample overlapping latent tiles.""" + + assembled_positive, assembled_negative = ( + cls.conditioning_service_class().assemble( + positive=positive, + negative=negative, + masks=region_masks, + regional_prompt_weight=regional_prompt_weight, + region_mask_feather=region_mask_feather, + ) + ) + output = cls.sampling_service_class().sample( + diffusion_mode=diffusion_mode, + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=assembled_positive, + negative=assembled_negative, + latent_image=latent_image, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=None, + allow_full_context_masks=True, + ) + return (output,) diff --git a/simple_syrup/nodes_v3/load_mask_batch.py b/simple_syrup/nodes_v3/load_mask_batch.py new file mode 100644 index 0000000..d63acfb --- /dev/null +++ b/simple_syrup/nodes_v3/load_mask_batch.py @@ -0,0 +1,110 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node for loading one or many authored masks with native widgets.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..runtime.mask_file_loader import MASK_CHANNELS +from ..services.load_mask_batch_service import LoadMaskBatchService + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_api: Any = None if TYPE_CHECKING else import_module("comfy_api.latest") +_comfy_io: Any = None if TYPE_CHECKING else _comfy_api.io +_comfy_ui: Any = None if TYPE_CHECKING else _comfy_api.UI + + +class LoadMaskBatchV3(_ComfyNodeBase): + """Load an ordered set of authored files as one Comfy MASK batch.""" + + service_class: ClassVar[type[LoadMaskBatchService]] = LoadMaskBatchService + + @classmethod + def define_schema(cls) -> Any: + """Declare a native image-upload combo that accepts one or many files.""" + + choices = list(cls.service_class().available_files()) + return _comfy_io.Schema( + node_id="SimpleSyrup.LoadMaskBatch", + display_name="Load Mask Batch", + category="SimpleSyrup/Loaders", + description=( + "Loads one or many authored mask files in selection order as a " + "single mask batch." + ), + search_aliases=["load masks", "mask batch", "regional masks"], + has_intermediate_output=True, + inputs=[ + _comfy_io.MultiCombo.Input( + "image", + options=choices, + default=[], + placeholder="Select one or more masks", + chip=True, + tooltip=( + "Select one or more ordered mask files; each selected " + "file becomes one regional mask." + ), + extra_dict={ + "image_upload": True, + "image_folder": "input", + "allow_batch": True, + }, + ), + _comfy_io.Combo.Input( + "channel", + options=list(MASK_CHANNELS), + default="alpha", + tooltip=( + "Image channel read from every file using ComfyUI mask " + "loading semantics." + ), + ), + ], + outputs=[ + _comfy_io.Mask.Output( + "mask", + tooltip="Ordered BHW mask batch containing one mask per file.", + ), + ], + ) + + @classmethod + def execute(cls, image: str | list[str], channel: str) -> Any: + """Load the selected files and return a native mask-batch preview.""" + + mask_batch = cls.service_class().load(image, channel) + return _comfy_io.NodeOutput( + mask_batch, + ui=_comfy_ui.PreviewMask(mask_batch, cls=cls), + ) + + @classmethod + def validate_inputs(cls, image: str | list[str], channel: str) -> bool | str: + """Validate native widget values before execution or filesystem reads.""" + + try: + cls.service_class().validate(image, channel) + except (TypeError, ValueError) as error: + return str(error) + return True + + @classmethod + def fingerprint_inputs(cls, image: str | list[str], channel: str) -> str: + """Fingerprint the ordered selected files and shared channel.""" + + return cls.service_class().fingerprint(image, channel) diff --git a/simple_syrup/nodes_v3/regional_ksampler_schema.py b/simple_syrup/nodes_v3/regional_ksampler_schema.py new file mode 100644 index 0000000..cde5f9a --- /dev/null +++ b/simple_syrup/nodes_v3/regional_ksampler_schema.py @@ -0,0 +1,169 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Shared Comfy v3 schema declarations for regional KSamplers.""" + +from __future__ import annotations + +from typing import Any + +from ..domain.regional_prompting import MAX_REGIONAL_PROMPT_WEIGHT +from ..domain.tiled_diffusion import TILED_DIFFUSION_MODES +from ..nodes import tooltips +from ..nodes.ksampler_tiled_diffusion import MAX_LATENT_TILE_SIZE +from ..runtime import sampling_samplers, sampling_schedulers + + +def regional_ksampler_inputs(comfy_io: Any) -> list[Any]: + """Return common regional KSampler inputs in workflow order.""" + + return [ + comfy_io.Model.Input("model", tooltip=tooltips.SAMPLING_MODEL), + comfy_io.Int.Input( + "seed", + default=0, + min=0, + max=0xFFFFFFFFFFFFFFFF, + control_after_generate=True, + tooltip=tooltips.SAMPLING_SEED, + ), + comfy_io.Int.Input( + "steps", + default=20, + min=1, + max=10000, + tooltip=tooltips.SAMPLING_STEPS, + ), + comfy_io.Float.Input( + "cfg", + default=8.0, + min=0.0, + max=100.0, + step=0.1, + round=0.01, + tooltip=tooltips.SAMPLING_CFG, + ), + comfy_io.Combo.Input( + "sampler_name", + options=list(sampling_samplers.available_samplers()), + tooltip=tooltips.SAMPLER_NAME, + ), + comfy_io.Combo.Input( + "scheduler", + options=list(sampling_schedulers.available_schedulers()), + tooltip=tooltips.SCHEDULER, + ), + *regional_conditioning_inputs(comfy_io), + comfy_io.Latent.Input("latent_image", tooltip=tooltips.LATENT_IMAGE), + comfy_io.Float.Input( + "denoise", + default=1.0, + min=0.0, + max=1.0, + step=0.01, + tooltip=tooltips.DENOISE_STRENGTH, + ), + ] + + +def regional_conditioning_inputs(comfy_io: Any) -> list[Any]: + """Return the authoritative ordered regional-composition inputs.""" + + conditioning_batch = comfy_io.Custom("CONDITIONING_BATCH") + return [ + comfy_io.MultiType.Input( + "positive", + [comfy_io.Conditioning, conditioning_batch], + tooltip=( + "Positive conditioning whose first batch entry is global and " + "later entries pair with masks in order." + ), + ), + comfy_io.MultiType.Input( + "negative", + [comfy_io.Conditioning, conditioning_batch], + tooltip=( + "Negative conditioning whose first batch entry is global and " + "later entries pair with masks in order." + ), + ), + comfy_io.Mask.Input( + "region_masks", + tooltip=( + "Ordered authored masks; mask 0 pairs with conditioning batch entry 1." + ), + ), + comfy_io.Float.Input( + "regional_prompt_weight", + default=0.5, + min=0.0, + max=MAX_REGIONAL_PROMPT_WEIGHT, + step=0.01, + round=0.01, + tooltip=( + "Balances regional prompts against the global prompt; 0 uses " + "only global prompting, 1 uses only regional prompting inside " + "solid masks, and overlaps reduce the global share further." + ), + ), + comfy_io.Int.Input( + "region_mask_feather", + default=0, + min=0, + max=512, + step=1, + tooltip=( + "Softens regional mask edges by this many image pixels; 0 " + "preserves authored mask values." + ), + ), + ] + + +def tiled_regional_inputs(comfy_io: Any) -> list[Any]: + """Return tile controls matching KSampler Tiled Diffusion.""" + + return [ + comfy_io.Combo.Input( + "diffusion_mode", + options=list(TILED_DIFFUSION_MODES), + default="multidiffusion", + tooltip=( + "Tiled blend method; MultiDiffusion is steady while Mixture of " + "Diffusers weights tile centers more strongly." + ), + ), + comfy_io.Int.Input( + "latent_tile_width", + default=128, + min=16, + max=MAX_LATENT_TILE_SIZE, + step=16, + tooltip=tooltips.LATENT_TILE_WIDTH, + ), + comfy_io.Int.Input( + "latent_tile_height", + default=128, + min=16, + max=MAX_LATENT_TILE_SIZE, + step=16, + tooltip=tooltips.LATENT_TILE_HEIGHT, + ), + comfy_io.Int.Input( + "latent_tile_overlap", + default=16, + min=0, + max=256, + step=4, + tooltip=tooltips.LATENT_TILE_OVERLAP, + ), + comfy_io.Int.Input( + "latent_tile_batch_size", + default=4, + min=1, + max=8, + step=1, + tooltip=tooltips.LATENT_TILE_BATCH_SIZE, + ), + ] diff --git a/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py b/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py index 5b33d56..994c2b0 100644 --- a/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py +++ b/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py @@ -49,8 +49,8 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): enable_expand=True, category="SimpleSyrup/Conditioning", description=( - "Schedules Prompt-Control LoRAs and encodes prompts, using [SEP] " - "to create SimpleSyrup conditioning batches." + "Schedules Prompt-Control LoRAs and encodes prompts. With [SEP], " + "each conditioning entry keeps only its segment's LoRA hooks." ), inputs=[ _comfy_io.Model.Input( @@ -84,8 +84,8 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): multiline=False, default="", tooltip=( - "Positive Prompt-Control text. [SEP] creates a " - "conditioning batch for SimpleSyrup batch-aware nodes." + "Positive Prompt-Control text; [SEP] creates ordered " + "conditioning entries with segment-local LoRA hooks." ), ), _comfy_io.String.Input( @@ -93,8 +93,8 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): multiline=False, default="", tooltip=( - "Negative Prompt-Control text. [SEP] creates a " - "conditioning batch for SimpleSyrup batch-aware nodes." + "Negative Prompt-Control text; [SEP] creates ordered " + "conditioning entries sharing each index's LoRA hooks." ), ), ], @@ -102,8 +102,8 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): _comfy_io.Model.Output( "model", tooltip=( - "Model after LoRA tags from positive and negative prompts " - "are scheduled." + "Model with single-prompt LoRAs applied globally; SEP-local " + "LoRAs travel on their conditioning entries instead." ), ), MixedConditioningIO.Output( diff --git a/simple_syrup/runtime/mask_batch_preview_routes.py b/simple_syrup/runtime/mask_batch_preview_routes.py new file mode 100644 index 0000000..7348d80 --- /dev/null +++ b/simple_syrup/runtime/mask_batch_preview_routes.py @@ -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) diff --git a/simple_syrup/runtime/mask_file_loader.py b/simple_syrup/runtime/mask_file_loader.py new file mode 100644 index 0000000..cc934fc --- /dev/null +++ b/simple_syrup/runtime/mask_file_loader.py @@ -0,0 +1,91 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI filesystem adapter for authored mask files.""" + +from __future__ import annotations + +import hashlib +from importlib import import_module +from pathlib import Path + +import torch +from PIL import Image + +MASK_CHANNELS: tuple[str, ...] = ("alpha", "red", "green", "blue") + + +class MaskFileLoader: + """Load one static mask through ComfyUI's native mask implementation.""" + + def available_files(self) -> tuple[str, ...]: + """Return choices declared by ComfyUI's native mask loader.""" + + declaration = import_module("nodes").LoadImageMask.INPUT_TYPES() + image_input = declaration["required"]["image"] + choices = image_input[0] + if not isinstance(choices, (list, tuple)): + raise TypeError("ComfyUI LoadImageMask returned invalid image choices.") + return tuple(str(value) for value in choices) + + def validate(self, annotated_path: str, channel: str) -> None: + """Validate one path with ComfyUI's native mask-loader contract.""" + + self._validate_channel(channel) + result = import_module("nodes").LoadImageMask.VALIDATE_INPUTS(annotated_path) + if result is not True: + raise ValueError(str(result)) + + def load(self, annotated_path: str, channel: str) -> torch.Tensor: + """Return exactly one BHW mask from a validated annotated path.""" + + self.validate(annotated_path, channel) + path = self._resolve_path(annotated_path) + with Image.open(path) as image: + frame_count = int(getattr(image, "n_frames", 1)) + if frame_count != 1: + raise ValueError( + f"Load Mask Batch requires one mask per file; {annotated_path!r} " + f"contains {frame_count} frames." + ) + + load_image_mask = import_module("nodes").LoadImageMask() + result = load_image_mask.load_image_mask(annotated_path, channel) + mask = result[0] + if not isinstance(mask, torch.Tensor): + raise TypeError(f"ComfyUI did not return a MASK for {annotated_path!r}.") + if mask.ndim != 3 or int(mask.shape[0]) != 1: + raise ValueError( + f"Load Mask Batch requires one mask per file; {annotated_path!r} " + f"returned shape {tuple(mask.shape)}." + ) + return mask.float().clamp(0.0, 1.0) + + def fingerprint(self, annotated_path: str) -> str: + """Return the content fingerprint for one validated mask file.""" + + path = self._resolve_path(annotated_path) + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def _resolve_path(self, annotated_path: str) -> Path: + """Resolve an existing Comfy annotated path without accepting arbitrary IO.""" + + if not isinstance(annotated_path, str) or not annotated_path: + raise ValueError("Load Mask Batch requires a non-empty mask file path.") + folder_paths = import_module("folder_paths") + if not folder_paths.exists_annotated_filepath(annotated_path): + raise ValueError(f"Mask file does not exist: {annotated_path!r}.") + return Path(folder_paths.get_annotated_filepath(annotated_path)) + + def _validate_channel(self, channel: str) -> None: + """Reject channels outside ComfyUI's native mask choices.""" + + if channel not in MASK_CHANNELS: + raise ValueError( + f"mask channel must be one of {MASK_CHANNELS}; received {channel!r}." + ) diff --git a/simple_syrup/runtime/mixture_of_diffusers_sampling.py b/simple_syrup/runtime/mixture_of_diffusers_sampling.py index dc35eb5..fda3501 100644 --- a/simple_syrup/runtime/mixture_of_diffusers_sampling.py +++ b/simple_syrup/runtime/mixture_of_diffusers_sampling.py @@ -62,6 +62,7 @@ def sample_mixture_of_diffusers( latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, differential_diffusion: bool = False, + allow_full_context_masks: bool = False, ) -> Latent: """Sample a latent with a cloned model patched for Mixture of Diffusers.""" @@ -72,8 +73,16 @@ def sample_mixture_of_diffusers( latent_tile_height=latent_tile_height, latent_tile_batch_size=latent_tile_batch_size, ) - reject_unsupported_conditioning(positive, sampler_label=SAMPLER_LABEL) - reject_unsupported_conditioning(negative, sampler_label=SAMPLER_LABEL) + reject_unsupported_conditioning( + positive, + sampler_label=SAMPLER_LABEL, + allow_full_context_masks=allow_full_context_masks, + ) + reject_unsupported_conditioning( + negative, + sampler_label=SAMPLER_LABEL, + allow_full_context_masks=allow_full_context_masks, + ) sampler = sampling_samplers.resolve_sampler(sampler_name) sigmas = sampling_schedulers.calculate_sigmas( diff --git a/simple_syrup/runtime/multidiffusion_sampling.py b/simple_syrup/runtime/multidiffusion_sampling.py index 472b213..c9a732a 100644 --- a/simple_syrup/runtime/multidiffusion_sampling.py +++ b/simple_syrup/runtime/multidiffusion_sampling.py @@ -62,6 +62,7 @@ def sample_multidiffusion( latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, differential_diffusion: bool = False, + allow_full_context_masks: bool = False, ) -> Latent: """Sample a latent with a cloned model patched for MultiDiffusion.""" @@ -73,8 +74,16 @@ def sample_multidiffusion( latent_tile_batch_size=latent_tile_batch_size, ) _reject_unipc_sampler(sampler_name) - reject_unsupported_conditioning(positive, sampler_label=SAMPLER_LABEL) - reject_unsupported_conditioning(negative, sampler_label=SAMPLER_LABEL) + reject_unsupported_conditioning( + positive, + sampler_label=SAMPLER_LABEL, + allow_full_context_masks=allow_full_context_masks, + ) + reject_unsupported_conditioning( + negative, + sampler_label=SAMPLER_LABEL, + allow_full_context_masks=allow_full_context_masks, + ) sampler = sampling_samplers.resolve_sampler(sampler_name) sigmas = sampling_schedulers.calculate_sigmas( diff --git a/simple_syrup/runtime/prompt_control_batch_graph.py b/simple_syrup/runtime/prompt_control_batch_graph.py index 5179a05..43f806a 100644 --- a/simple_syrup/runtime/prompt_control_batch_graph.py +++ b/simple_syrup/runtime/prompt_control_batch_graph.py @@ -2,16 +2,17 @@ # Copyright (C) 2026 Artificial Sweetener and contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""Runtime graph expansion for Prompt Control conditioning batches.""" +"""Orchestrate Prompt Control SEP conditioning-batch graph expansion.""" from __future__ import annotations -import sys -from importlib import import_module -from typing import Any, cast +from typing import Any -from ..domain.conditioning_batch import split_prompt_batch -from .prompt_control_availability import find_prompt_control_install +from ..domain.prompt_control_prompt import PreparedPromptSide +from ..services.prompt_control_segment_planning_service import ( + PromptControlSegmentPlanningService, +) +from .prompt_control_graph_adapter import PromptControlGraphAdapter PROMPT_CONTROL_MISSING_MESSAGE = ( "Encode Prompt Batch w/ Prompt Control requires comfyui-prompt-control. " @@ -20,7 +21,10 @@ PROMPT_CONTROL_MISSING_MESSAGE = ( class PromptControlBatchGraphBuilder: - """Build lazy Prompt Control graphs that return conditioning batches.""" + """Build segment-local hook-aware Prompt Control conditioning batches.""" + + planning_service_class = PromptControlSegmentPlanningService + graph_adapter_class = PromptControlGraphAdapter def build( self, @@ -29,105 +33,63 @@ class PromptControlBatchGraphBuilder: negative_prompt: str, separator: str, ) -> Any: - """Return an io.NodeOutput with positive and negative batch links.""" + """Return positive and negative conditioning-batch graph links.""" - io, graph_utils, lazy_node = self._prompt_control_dependencies() - positive_chunks = split_prompt_batch(positive_prompt, separator) - negative_chunks = split_prompt_batch(negative_prompt, separator) - - expand: dict[str, dict[str, Any]] = {} - positive_output, positive_expand = self._encode_chunks( - chunks=positive_chunks, - clip=clip, - graph_utils=graph_utils, - lazy_node=lazy_node, + plan = self.planning_service_class().prepare( + positive_prompt=positive_prompt, + negative_prompt=negative_prompt, + separator=separator, ) - negative_output, negative_expand = self._encode_chunks( - chunks=negative_chunks, - clip=clip, - graph_utils=graph_utils, - lazy_node=lazy_node, - ) - expand.update(positive_expand) - expand.update(negative_expand) - return io.NodeOutput(positive_output, negative_output, expand=expand) - - def _encode_chunks( - self, - chunks: tuple[str, ...], - clip: Any, - graph_utils: Any, - lazy_node: Any, - ) -> tuple[list[Any], dict[str, dict[str, Any]]]: - """Encode chunks with Prompt Control and pack the resulting links.""" - + adapter = self.graph_adapter_class.load(PROMPT_CONTROL_MISSING_MESSAGE) expand: dict[str, dict[str, Any]] = {} - conditioning_outputs: list[Any] = [] - for chunk in chunks: - node_output = lazy_node.execute( + segment_clips = tuple( + adapter.clip_with_hooks( clip=clip, - text=chunk, - tags="", - start=0.0, - end=1.0, - num_steps=0, + lora_tags=hook.lora_tags, + expand=expand, + label=f"segment {index}", ) - node_expand = cast(dict[str, dict[str, Any]], node_output.expand or {}) - overlap = set(expand).intersection(node_expand) - if overlap: - overlapping_ids = ", ".join(sorted(overlap)) - raise ValueError( - "Prompt Control generated duplicate graph node ids: " - f"{overlapping_ids}." - ) - expand.update(node_expand) - conditioning_outputs.append(node_output.args[0]) - - pack_graph = graph_utils.GraphBuilder() - current = pack_graph.node( - "SimpleSyrup.ConditioningBatchStart", - conditioning=conditioning_outputs[0], + for index, hook in enumerate(plan.hooks) ) - for conditioning in conditioning_outputs[1:]: - current = pack_graph.node( - "SimpleSyrup.ConditioningBatchAppend", - batch=current.out(0), - conditioning=conditioning, + positive = self._encode_side( + plan.positive, + segment_clips=segment_clips, + adapter=adapter, + expand=expand, + label="positive", + ) + negative = self._encode_side( + plan.negative, + segment_clips=segment_clips, + adapter=adapter, + expand=expand, + label="negative", + ) + return adapter.io.NodeOutput(positive, negative, expand=expand) + + def _encode_side( + self, + side: PreparedPromptSide, + *, + segment_clips: tuple[Any, ...], + adapter: PromptControlGraphAdapter, + expand: dict[str, dict[str, Any]], + label: str, + ) -> Any: + """Encode and pack all existing chunks from one prompt side.""" + + outputs = [ + adapter.encode_segment( + clip=segment_clips[index], + text=chunk.text, + expand=expand, + label=f"{label} segment {index}", ) - pack_expand = cast(dict[str, dict[str, Any]], pack_graph.finalize()) - overlap = set(expand).intersection(pack_expand) - if overlap: - overlapping_ids = ", ".join(sorted(overlap)) - raise ValueError( - f"SimpleSyrup generated duplicate graph node ids: {overlapping_ids}." - ) - expand.update(pack_expand) - return current.out(0), expand - - def _prompt_control_dependencies(self) -> tuple[Any, Any, Any]: - """Import Prompt Control and Comfy v3 dependencies on demand.""" - - try: - io = import_module("comfy_api.latest.io") - except ModuleNotFoundError: - comfy_api = import_module("comfy_api.latest") - io = comfy_api.io - try: - graph_utils = import_module("comfy_execution.graph_utils") - lazy_nodes = self._import_prompt_control_lazy_nodes() - except ModuleNotFoundError as exc: - raise RuntimeError(PROMPT_CONTROL_MISSING_MESSAGE) from exc - return io, graph_utils, lazy_nodes.PCLazyTextEncodeAdvanced - - def _import_prompt_control_lazy_nodes(self) -> Any: - """Import Prompt Control lazy nodes from normal or sibling extension paths.""" - - try: - return import_module("prompt_control.nodes_lazy") - except ModuleNotFoundError: - availability = find_prompt_control_install() - if availability.root_path is not None: - root_path = str(availability.root_path) - if root_path not in sys.path: - sys.path.insert(0, root_path) - return import_module("prompt_control.nodes_lazy") + for index, chunk in enumerate(side.chunks) + ] + return adapter.pack_conditionings( + outputs, + expand=expand, + label=label, + always_batch=True, + ) diff --git a/simple_syrup/runtime/prompt_control_graph_adapter.py b/simple_syrup/runtime/prompt_control_graph_adapter.py new file mode 100644 index 0000000..fca4347 --- /dev/null +++ b/simple_syrup/runtime/prompt_control_graph_adapter.py @@ -0,0 +1,187 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Adapt Prompt Control and Comfy graph nodes for SEP prompt orchestration.""" + +from __future__ import annotations + +import sys +from importlib import import_module +from typing import Any, cast + +from .prompt_control_availability import find_prompt_control_install + + +class PromptControlGraphAdapter: + """Build hook-aware lazy graph fragments behind one runtime boundary.""" + + def __init__(self, io: Any, graph_utils: Any, lazy_nodes: Any) -> None: + """Store imported host graph APIs for one expansion request.""" + + self.io = io + self._graph_utils = graph_utils + self._lazy_nodes = lazy_nodes + + @classmethod + def load(cls, missing_message: str) -> PromptControlGraphAdapter: + """Load Comfy and Prompt Control graph APIs or raise an actionable error.""" + + try: + io = import_module("comfy_api.latest.io") + except ModuleNotFoundError: + io = import_module("comfy_api.latest").io + try: + graph_utils = import_module("comfy_execution.graph_utils") + lazy_nodes = cls._import_prompt_control_lazy_nodes() + except ModuleNotFoundError as exc: + raise RuntimeError(missing_message) from exc + return cls(io, graph_utils, lazy_nodes) + + def schedule_global_loras( + self, + *, + model: Any, + clip: Any, + positive_tags: str, + negative_tags: str, + expand: dict[str, dict[str, Any]], + ) -> tuple[Any, Any]: + """Preserve single-segment model/CLIP scheduling behavior.""" + + positive = self._lazy_nodes.PCLazyLoraLoaderAdvanced.execute( + model=model, + clip=clip, + text=positive_tags, + apply_hooks=True, + tags="", + start=0.0, + end=1.0, + num_steps=0, + ) + self.merge_expand(expand, positive.expand, "positive LoRA scheduling") + negative = self._lazy_nodes.PCLazyLoraLoaderAdvanced.execute( + model=positive.args[0], + clip=positive.args[1], + text=negative_tags, + apply_hooks=True, + tags="", + start=0.0, + end=1.0, + num_steps=0, + ) + self.merge_expand(expand, negative.expand, "negative LoRA scheduling") + return negative.args[0], negative.args[1] + + def encode_segment( + self, + *, + clip: Any, + text: str, + expand: dict[str, dict[str, Any]], + label: str, + ) -> Any: + """Encode one segment with a previously prepared CLIP link.""" + + output = self._lazy_nodes.PCLazyTextEncodeAdvanced.execute( + clip=clip, + text=text, + tags="", + start=0.0, + end=1.0, + num_steps=0, + ) + self.merge_expand(expand, output.expand, f"{label} text encoding") + return output.args[0] + + def clip_with_hooks( + self, + *, + clip: Any, + lora_tags: str, + expand: dict[str, dict[str, Any]], + label: str, + ) -> Any: + """Return a CLIP link sharing one segment's hooks across prompt sides.""" + + if not lora_tags: + return clip + graph = self._graph_utils.GraphBuilder() + hooks = graph.node("PCLoraHooksFromText", text=lora_tags) + hooked_clip = graph.node( + "SetClipHooks", + clip=clip, + hooks=hooks.out(0), + apply_to_conds=True, + schedule_clip=True, + ) + self.merge_expand( + expand, + cast(dict[str, dict[str, Any]], graph.finalize()), + f"{label} LoRA hooks", + ) + return hooked_clip.out(0) + + def pack_conditionings( + self, + conditionings: list[Any], + *, + expand: dict[str, dict[str, Any]], + label: str, + always_batch: bool, + ) -> Any: + """Return one conditioning or a SimpleSyrup conditioning batch link.""" + + if len(conditionings) == 1 and not always_batch: + return conditionings[0] + graph = self._graph_utils.GraphBuilder() + current = graph.node( + "SimpleSyrup.ConditioningBatchStart", + conditioning=conditionings[0], + ) + for conditioning in conditionings[1:]: + current = graph.node( + "SimpleSyrup.ConditioningBatchAppend", + batch=current.out(0), + conditioning=conditioning, + ) + self.merge_expand( + expand, + cast(dict[str, dict[str, Any]], graph.finalize()), + f"{label} batch packing", + ) + return current.out(0) + + def merge_expand( + self, + target: dict[str, dict[str, Any]], + source: object, + operation: str, + ) -> None: + """Merge a graph fragment while rejecting duplicate generated ids.""" + + if not source: + return + fragment = cast(dict[str, dict[str, Any]], source) + overlap = set(target).intersection(fragment) + if overlap: + overlapping_ids = ", ".join(sorted(overlap)) + raise ValueError( + "Prompt-Control graph expansion generated duplicate node ids " + f"during {operation}: {overlapping_ids}." + ) + target.update(fragment) + + @staticmethod + def _import_prompt_control_lazy_nodes() -> Any: + """Import Prompt Control from its normal or sibling extension path.""" + + try: + return import_module("prompt_control.nodes_lazy") + except ModuleNotFoundError: + availability = find_prompt_control_install() + if availability.root_path is not None: + root_path = str(availability.root_path) + if root_path not in sys.path: + sys.path.insert(0, root_path) + return import_module("prompt_control.nodes_lazy") diff --git a/simple_syrup/runtime/prompt_control_schedule_encode_graph.py b/simple_syrup/runtime/prompt_control_schedule_encode_graph.py index 4a34052..b2d7ced 100644 --- a/simple_syrup/runtime/prompt_control_schedule_encode_graph.py +++ b/simple_syrup/runtime/prompt_control_schedule_encode_graph.py @@ -2,20 +2,18 @@ # Copyright (C) 2026 Artificial Sweetener and contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""Runtime graph expansion for Prompt-Control scheduling and prompt encoding.""" +"""Orchestrate Prompt-Control model scheduling and SEP prompt encoding.""" from __future__ import annotations -import sys -from importlib import import_module -from typing import Any, cast +from typing import Any -from ..domain.prompt_control_prompt import ( - PreparedPromptSide, - apply_encode_style, - prepare_prompt_side, +from ..domain.prompt_control_prompt import PreparedPromptSide, apply_encode_style +from ..services.prompt_control_segment_planning_service import ( + PromptControlSegmentPlan, + PromptControlSegmentPlanningService, ) -from .prompt_control_availability import find_prompt_control_install +from .prompt_control_graph_adapter import PromptControlGraphAdapter PROMPT_CONTROL_MISSING_MESSAGE = ( "Schedule & Encode Prompts requires comfyui-prompt-control. " @@ -25,7 +23,10 @@ PROMPT_BATCH_SEPARATOR = "[SEP]" class PromptControlScheduleEncodeGraphBuilder: - """Build lazy Prompt-Control graphs for LoRA scheduling and prompt encoding.""" + """Build scheduled model and hook-aware conditioning graph outputs.""" + + planning_service_class = PromptControlSegmentPlanningService + graph_adapter_class = PromptControlGraphAdapter def build( self, @@ -35,161 +36,118 @@ class PromptControlScheduleEncodeGraphBuilder: negative_prompt: str, encode_style: str = "", ) -> Any: - """Return an io.NodeOutput for scheduled model and encoded prompts.""" - - io, graph_utils, lazy_nodes = self._prompt_control_dependencies() - positive_side = prepare_prompt_side(positive_prompt, PROMPT_BATCH_SEPARATOR) - negative_side = prepare_prompt_side(negative_prompt, PROMPT_BATCH_SEPARATOR) + """Return model plus single or SEP-batched conditioning outputs.""" + plan = self.planning_service_class().prepare( + positive_prompt=positive_prompt, + negative_prompt=negative_prompt, + separator=PROMPT_BATCH_SEPARATOR, + ) + adapter = self.graph_adapter_class.load(PROMPT_CONTROL_MISSING_MESSAGE) expand: dict[str, dict[str, Any]] = {} - positive_lora = lazy_nodes.PCLazyLoraLoaderAdvanced.execute( + scheduled_model, encoding_clip = self._sampling_inputs( model=model, clip=clip, - text=positive_side.lora_tags, - apply_hooks=True, - tags="", - start=0.0, - end=1.0, - num_steps=0, - ) - self._merge_expand(expand, positive_lora.expand, "positive LoRA scheduling") - - negative_lora = lazy_nodes.PCLazyLoraLoaderAdvanced.execute( - model=positive_lora.args[0], - clip=positive_lora.args[1], - text=negative_side.lora_tags, - apply_hooks=True, - tags="", - start=0.0, - end=1.0, - num_steps=0, - ) - self._merge_expand(expand, negative_lora.expand, "negative LoRA scheduling") - - scheduled_model = negative_lora.args[0] - scheduled_clip = negative_lora.args[1] - positive_conditioning = self._encode_side( - side=positive_side, - clip=scheduled_clip, - encode_style=encode_style, - graph_utils=graph_utils, - lazy_text_encoder=lazy_nodes.PCLazyTextEncodeAdvanced, + plan=plan, + adapter=adapter, expand=expand, - label="positive prompt encoding", ) - negative_conditioning = self._encode_side( - side=negative_side, - clip=scheduled_clip, - encode_style=encode_style, - graph_utils=graph_utils, - lazy_text_encoder=lazy_nodes.PCLazyTextEncodeAdvanced, + segment_clips = self._segment_clips( + plan=plan, + clip=encoding_clip, + adapter=adapter, expand=expand, - label="negative prompt encoding", ) - - return io.NodeOutput( + positive = self._encode_side( + plan.positive, + segment_clips=segment_clips, + encode_style=encode_style, + adapter=adapter, + expand=expand, + label="positive", + ) + negative = self._encode_side( + plan.negative, + segment_clips=segment_clips, + encode_style=encode_style, + adapter=adapter, + expand=expand, + label="negative", + ) + return adapter.io.NodeOutput( scheduled_model, - positive_conditioning, - negative_conditioning, + positive, + negative, expand=expand, ) + def _sampling_inputs( + self, + *, + model: Any, + clip: Any, + plan: PromptControlSegmentPlan, + adapter: PromptControlGraphAdapter, + expand: dict[str, dict[str, Any]], + ) -> tuple[Any, Any]: + """Keep single prompts global and batched prompts segment-local.""" + + if plan.is_batched: + return model, clip + return adapter.schedule_global_loras( + model=model, + clip=clip, + positive_tags=plan.positive.chunks[0].lora_tags, + negative_tags=plan.negative.chunks[0].lora_tags, + expand=expand, + ) + + def _segment_clips( + self, + *, + plan: PromptControlSegmentPlan, + clip: Any, + adapter: PromptControlGraphAdapter, + expand: dict[str, dict[str, Any]], + ) -> tuple[Any, ...]: + """Create one shared hooked CLIP link per batched segment index.""" + + if not plan.is_batched: + return (clip,) + return tuple( + adapter.clip_with_hooks( + clip=clip, + lora_tags=hook.lora_tags, + expand=expand, + label=f"segment {index}", + ) + for index, hook in enumerate(plan.hooks) + ) + def _encode_side( self, - *, side: PreparedPromptSide, - clip: Any, + *, + segment_clips: tuple[Any, ...], encode_style: str, - graph_utils: Any, - lazy_text_encoder: Any, + adapter: PromptControlGraphAdapter, expand: dict[str, dict[str, Any]], label: str, ) -> Any: - """Encode one prompt side and return conditioning or conditioning batch.""" + """Encode one side with the shared hook plan at each existing index.""" - conditioning_outputs: list[Any] = [] - for index, chunk in enumerate(side.chunks): - text = apply_encode_style(encode_style, chunk.text) - node_output = lazy_text_encoder.execute( - clip=clip, - text=text, - tags="", - start=0.0, - end=1.0, - num_steps=0, + outputs = [ + adapter.encode_segment( + clip=segment_clips[index], + text=apply_encode_style(encode_style, chunk.text), + expand=expand, + label=f"{label} segment {index}", ) - self._merge_expand( - expand, - node_output.expand, - f"{label} chunk {index}", - ) - conditioning_outputs.append(node_output.args[0]) - - if len(conditioning_outputs) == 1: - return conditioning_outputs[0] - - pack_graph = graph_utils.GraphBuilder() - current = pack_graph.node( - "SimpleSyrup.ConditioningBatchStart", - conditioning=conditioning_outputs[0], + for index, chunk in enumerate(side.chunks) + ] + return adapter.pack_conditionings( + outputs, + expand=expand, + label=label, + always_batch=False, ) - for conditioning in conditioning_outputs[1:]: - current = pack_graph.node( - "SimpleSyrup.ConditioningBatchAppend", - batch=current.out(0), - conditioning=conditioning, - ) - self._merge_expand( - expand, - cast(dict[str, dict[str, Any]], pack_graph.finalize()), - f"{label} batch packing", - ) - return current.out(0) - - def _prompt_control_dependencies(self) -> tuple[Any, Any, Any]: - """Import Prompt-Control and Comfy graph helpers on demand.""" - - try: - io = import_module("comfy_api.latest.io") - except ModuleNotFoundError: - comfy_api = import_module("comfy_api.latest") - io = comfy_api.io - try: - graph_utils = import_module("comfy_execution.graph_utils") - lazy_nodes = self._import_prompt_control_lazy_nodes() - except ModuleNotFoundError as exc: - raise RuntimeError(PROMPT_CONTROL_MISSING_MESSAGE) from exc - return io, graph_utils, lazy_nodes - - def _import_prompt_control_lazy_nodes(self) -> Any: - """Import Prompt-Control lazy nodes from installed or sibling paths.""" - - try: - return import_module("prompt_control.nodes_lazy") - except ModuleNotFoundError: - availability = find_prompt_control_install() - if availability.root_path is not None: - root_path = str(availability.root_path) - if root_path not in sys.path: - sys.path.insert(0, root_path) - return import_module("prompt_control.nodes_lazy") - - def _merge_expand( - self, - target: dict[str, dict[str, Any]], - source: object, - operation: str, - ) -> None: - """Merge a lazy expand graph and reject duplicate generated node ids.""" - - if not source: - return - expand = cast(dict[str, dict[str, Any]], source) - overlap = set(target).intersection(expand) - if overlap: - overlapping_ids = ", ".join(sorted(overlap)) - raise ValueError( - f"Prompt-Control graph expansion generated duplicate node ids " - f"during {operation}: {overlapping_ids}." - ) - target.update(expand) diff --git a/simple_syrup/runtime/tiled_sampling.py b/simple_syrup/runtime/tiled_sampling.py index e0f17f7..1c1503e 100644 --- a/simple_syrup/runtime/tiled_sampling.py +++ b/simple_syrup/runtime/tiled_sampling.py @@ -21,7 +21,7 @@ Latent: TypeAlias = dict[str, Any] ApplyModel: TypeAlias = Callable[..., torch.Tensor] ModelFunctionWrapper: TypeAlias = Callable[[ApplyModel, dict[str, Any]], torch.Tensor] -UNSUPPORTED_CONDITIONING_KEYS = frozenset({"area", "mask", "control", "gligen"}) +UNSUPPORTED_CONDITIONING_KEYS = frozenset({"area", "control", "gligen"}) def validate_sampling_controls( @@ -89,27 +89,49 @@ def reject_unsupported_conditioning( conditioning: object, *, sampler_label: str, + allow_full_context_masks: bool = False, ) -> None: - """Reject regional and external-control conditioning for basic tiled samplers.""" + """Reject conditioning that the selected tiled path cannot preserve.""" - if contains_unsupported_conditioning_key(conditioning): + if contains_unsupported_conditioning_key( + conditioning, + allow_full_context_masks=allow_full_context_masks, + ): raise ValueError( f"{sampler_label} does not support regional conditioning or " "ControlNet in the first implementation." ) -def contains_unsupported_conditioning_key(value: object) -> bool: - """Return whether a nested conditioning object contains unsupported keys.""" +def contains_unsupported_conditioning_key( + value: object, + *, + allow_full_context_masks: bool = False, +) -> bool: + """Return whether nested conditioning exceeds the tiled support policy.""" if isinstance(value, dict): if any(key in UNSUPPORTED_CONDITIONING_KEYS for key in value): return True + if "mask" in value and ( + not allow_full_context_masks or value.get("set_area_to_bounds") is not False + ): + return True return any( - contains_unsupported_conditioning_key(item) for item in value.values() + contains_unsupported_conditioning_key( + item, + allow_full_context_masks=allow_full_context_masks, + ) + for item in value.values() ) if isinstance(value, list | tuple): - return any(contains_unsupported_conditioning_key(item) for item in value) + return any( + contains_unsupported_conditioning_key( + item, + allow_full_context_masks=allow_full_context_masks, + ) + for item in value + ) return False diff --git a/simple_syrup/services/ksampler_sampling_service.py b/simple_syrup/services/ksampler_sampling_service.py new file mode 100644 index 0000000..f3fb921 --- /dev/null +++ b/simple_syrup/services/ksampler_sampling_service.py @@ -0,0 +1,186 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Application service for ordinary KSampler-style latent sampling.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any, TypeAlias + +import torch + +from ..domain.conditioning_batch import ConditioningBatch, select_conditioning +from ..runtime import sampling_samplers, sampling_schedulers +from ..shared.logging import get_logger + +Latent: TypeAlias = dict[str, Any] +LOGGER = get_logger(__name__) + + +class KSamplerSamplingService: + """Own reusable full-latent sampling while preserving batch semantics.""" + + def sample( + self, + *, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + ) -> Latent: + """Sample a latent with configured SimpleSyrup sampler extensions.""" + + sampler = sampling_samplers.resolve_sampler(sampler_name) + sigmas = sampling_schedulers.calculate_sigmas( + model=model, + scheduler_name=scheduler, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + ).to(model.load_device) + latent_samples = latent_image["samples"] + if not isinstance(latent_samples, torch.Tensor): + raise TypeError("KSampler latent samples must be a torch.Tensor.") + + comfy_sample = import_module("comfy.sample") + comfy_utils = import_module("comfy.utils") + latent_samples = comfy_sample.fix_empty_latent_channels( + model, + latent_samples, + latent_image.get("downscale_ratio_spacial", None), + ) + if not isinstance(latent_samples, torch.Tensor): + raise TypeError( + "KSampler normalized latent samples must be a torch.Tensor." + ) + noise = comfy_sample.prepare_noise( + latent_samples, + seed, + latent_image.get("batch_index"), + ) + noise_mask = latent_image.get("noise_mask") + callback = import_module("latent_preview").prepare_callback(model, steps) + disable_pbar = not comfy_utils.PROGRESS_BAR_ENABLED + if self._uses_conditioning_batch(positive, negative): + samples = self._sample_conditioning_batch( + comfy_sample=comfy_sample, + model=model, + noise=noise, + cfg=cfg, + sampler=sampler, + sigmas=sigmas, + positive=positive, + negative=negative, + latent_samples=latent_samples, + noise_mask=noise_mask, + callback=callback, + disable_pbar=disable_pbar, + seed=seed, + ) + else: + samples = comfy_sample.sample_custom( + model, + noise, + cfg, + sampler, + sigmas, + positive, + negative, + latent_samples, + noise_mask=noise_mask, + callback=callback, + disable_pbar=disable_pbar, + seed=seed, + ) + if not isinstance(samples, torch.Tensor): + raise TypeError("KSampler output samples must be a torch.Tensor.") + + output = latent_image.copy() + output.pop("downscale_ratio_spacial", None) + output["samples"] = samples + LOGGER.info( + "KSampler pass completed", + extra={ + "operation": "ksampler_sample", + "sampler": sampler_name, + "scheduler": scheduler, + "steps": steps, + "denoise": denoise, + "latent_batch_size": int(samples.shape[0]), + "latent_height": int(samples.shape[-2]), + "latent_width": int(samples.shape[-1]), + }, + ) + return output + + def _sample_conditioning_batch( + self, + *, + comfy_sample: Any, + model: Any, + noise: torch.Tensor, + cfg: float, + sampler: Any, + sigmas: torch.Tensor, + positive: Any, + negative: Any, + latent_samples: torch.Tensor, + noise_mask: Any, + callback: Any, + disable_pbar: bool, + seed: int, + ) -> torch.Tensor: + """Sample latent items with existing per-item batch selection.""" + + sampled: list[torch.Tensor] = [] + for index in range(int(latent_samples.shape[0])): + sampled.append( + comfy_sample.sample_custom( + model, + noise[index : index + 1], + cfg, + sampler, + sigmas, + select_conditioning(positive, index), + select_conditioning(negative, index), + latent_samples[index : index + 1], + noise_mask=self._slice_noise_mask( + noise_mask, + index, + latent_samples, + ), + callback=callback, + disable_pbar=disable_pbar, + seed=seed, + ) + ) + return torch.cat(sampled, dim=0) + + def _slice_noise_mask( + self, + noise_mask: Any, + index: int, + latent_samples: torch.Tensor, + ) -> Any: + """Return a per-item noise mask when its batch matches the latent.""" + + if isinstance(noise_mask, torch.Tensor) and noise_mask.shape[0] == int( + latent_samples.shape[0] + ): + return noise_mask[index : index + 1] + return noise_mask + + def _uses_conditioning_batch(self, positive: Any, negative: Any) -> bool: + """Return whether existing per-latent conditioning selection applies.""" + + return isinstance(positive, ConditioningBatch) or isinstance( + negative, ConditioningBatch + ) diff --git a/simple_syrup/services/load_mask_batch_service.py b/simple_syrup/services/load_mask_batch_service.py new file mode 100644 index 0000000..3fd4106 --- /dev/null +++ b/simple_syrup/services/load_mask_batch_service.py @@ -0,0 +1,97 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Application service for ordered authored-mask loading.""" + +from __future__ import annotations + +import hashlib +from collections.abc import Sequence +from typing import ClassVar + +import torch + +from ..runtime.mask_file_loader import MaskFileLoader +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + + +class LoadMaskBatchService: + """Load ordered files into one dimensionally consistent MASK batch.""" + + loader_class: ClassVar[type[MaskFileLoader]] = MaskFileLoader + + def validate(self, files: Sequence[str], channel: str) -> None: + """Validate every selected file through the native Comfy mask loader.""" + + ordered_files = self._validate_files(files) + loader = self.loader_class() + for path in ordered_files: + loader.validate(path, channel) + + def load(self, files: Sequence[str], channel: str) -> torch.Tensor: + """Load one or many authored masks without changing their order.""" + + masks = self.load_each(files, channel) + expected_shape = tuple(masks[0].shape[1:]) + for index, mask in enumerate(masks[1:], start=1): + if tuple(mask.shape[1:]) != expected_shape: + raise ValueError( + "Load Mask Batch requires every mask to have identical " + f"dimensions; mask 0 is {expected_shape[0]}x{expected_shape[1]} " + f"but mask {index} is {mask.shape[1]}x{mask.shape[2]}." + ) + batch = torch.cat(masks, dim=0) + LOGGER.info( + "Authored mask batch loaded", + extra={ + "operation": "load_mask_batch", + "mask_count": int(batch.shape[0]), + "mask_height": int(batch.shape[1]), + "mask_width": int(batch.shape[2]), + "channel": channel, + }, + ) + return batch + + def load_each(self, files: Sequence[str], channel: str) -> tuple[torch.Tensor, ...]: + """Load ordered masks without requiring batch-compatible dimensions.""" + + ordered_files = self._validate_files(files) + loader = self.loader_class() + return tuple(loader.load(path, channel) for path in ordered_files) + + def fingerprint(self, files: Sequence[str], channel: str) -> str: + """Return an order-sensitive fingerprint for files and channel.""" + + ordered_files = self._validate_files(files) + loader = self.loader_class() + digest = hashlib.sha256() + digest.update(channel.encode("utf-8")) + for path in ordered_files: + encoded_path = path.encode("utf-8") + digest.update(len(encoded_path).to_bytes(8, "big")) + digest.update(encoded_path) + digest.update(loader.fingerprint(path).encode("ascii")) + return digest.hexdigest() + + def available_files(self) -> tuple[str, ...]: + """Return Comfy input images eligible for selection.""" + + return self.loader_class().available_files() + + def _validate_files(self, files: Sequence[str]) -> tuple[str, ...]: + """Return a non-empty ordered immutable file sequence.""" + + ordered: tuple[str, ...] + if isinstance(files, str): + ordered = (files,) + else: + ordered = tuple(files) + if not ordered: + raise ValueError("Load Mask Batch requires at least one mask file.") + if any(not isinstance(path, str) or not path for path in ordered): + raise TypeError("Load Mask Batch mask files must be non-empty strings.") + return ordered diff --git a/simple_syrup/services/prompt_control_segment_planning_service.py b/simple_syrup/services/prompt_control_segment_planning_service.py new file mode 100644 index 0000000..ae325c3 --- /dev/null +++ b/simple_syrup/services/prompt_control_segment_planning_service.py @@ -0,0 +1,73 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Plan aligned Prompt-Control SEP segments without runtime dependencies.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..domain.prompt_control_prompt import ( + PreparedPromptSide, + PromptSegmentHookPlan, + prepare_prompt_side, +) + + +@dataclass(frozen=True) +class PromptControlSegmentPlan: + """Store two prompt sides and the LoRA hooks shared at each position.""" + + positive: PreparedPromptSide + negative: PreparedPromptSide + hooks: tuple[PromptSegmentHookPlan, ...] + + @property + def is_batched(self) -> bool: + """Return whether either prompt side contains multiple SEP segments.""" + + return len(self.positive.chunks) > 1 or len(self.negative.chunks) > 1 + + +class PromptControlSegmentPlanningService: + """Prepare prompt sides and align their segment-local LoRA schedules.""" + + def prepare( + self, + *, + positive_prompt: str, + negative_prompt: str, + separator: str, + ) -> PromptControlSegmentPlan: + """Return a deterministic plan without padding either prompt side.""" + + positive = prepare_prompt_side(positive_prompt, separator) + negative = prepare_prompt_side(negative_prompt, separator) + hook_count = max(len(positive.chunks), len(negative.chunks)) + hooks = tuple( + PromptSegmentHookPlan( + lora_tags=self._combined_lora_tags(positive, negative, index) + ) + for index in range(hook_count) + ) + return PromptControlSegmentPlan( + positive=positive, + negative=negative, + hooks=hooks, + ) + + def _combined_lora_tags( + self, + positive: PreparedPromptSide, + negative: PreparedPromptSide, + index: int, + ) -> str: + """Join existing positive then negative tags for one segment index.""" + + tags: list[str] = [] + if index < len(positive.chunks) and positive.chunks[index].lora_tags: + tags.append(positive.chunks[index].lora_tags) + if index < len(negative.chunks) and negative.chunks[index].lora_tags: + tags.append(negative.chunks[index].lora_tags) + return "\n".join(tags) diff --git a/simple_syrup/services/regional_conditioning_service.py b/simple_syrup/services/regional_conditioning_service.py new file mode 100644 index 0000000..6bdf33c --- /dev/null +++ b/simple_syrup/services/regional_conditioning_service.py @@ -0,0 +1,163 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Application service for assembling full-context regional conditioning.""" + +from __future__ import annotations + +from typing import Any, TypeAlias + +import torch + +from ..domain.conditioning_batch import ConditioningBatch +from ..domain.regional_prompting import ( + build_regional_conditioning_plan, + validate_regional_prompt_weight, +) +from ..masking.regional_prompt_masks import ( + complementary_global_prompt_mask, + prepare_regional_mask_batch, + regional_mask, +) +from ..shared.logging import get_logger + +Conditioning: TypeAlias = list[list[Any]] +LOGGER = get_logger(__name__) + + +class RegionalConditioningService: + """Combine global and ordered regional conditioning without sampling.""" + + def assemble( + self, + *, + positive: object, + negative: object, + masks: object, + regional_prompt_weight: float, + region_mask_feather: int, + ) -> tuple[Conditioning, Conditioning]: + """Return standard Comfy positive and negative masked conditioning.""" + + validate_regional_prompt_weight(regional_prompt_weight) + mask_batch = prepare_regional_mask_batch(masks, region_mask_feather) + assembled_positive = self._assemble_input( + positive, + mask_batch, + regional_prompt_weight=regional_prompt_weight, + input_name="positive", + ) + assembled_negative = self._assemble_input( + negative, + mask_batch, + regional_prompt_weight=regional_prompt_weight, + input_name="negative", + ) + LOGGER.info( + "Regional conditioning assembled", + extra={ + "operation": "assemble_regional_conditioning", + "region_count": int(mask_batch.shape[0]), + "positive_entry_count": len(assembled_positive), + "negative_entry_count": len(assembled_negative), + "regional_prompt_weight": regional_prompt_weight, + "region_mask_feather": region_mask_feather, + }, + ) + return assembled_positive, assembled_negative + + def _assemble_input( + self, + value: object, + mask_batch: torch.Tensor, + *, + regional_prompt_weight: float, + input_name: str, + ) -> Conditioning: + """Assemble one global-first conditioning input.""" + + if isinstance(value, ConditioningBatch): + entries = value.entries + else: + entries = (value,) + plan = build_regional_conditioning_plan( + region_count=int(mask_batch.shape[0]), + conditioning_count=len(entries), + input_name=input_name, + ) + global_conditioning = self._validate_conditioning( + entries[0], + input_name=f"{input_name} global", + ) + if not plan.pairs or regional_prompt_weight == 0.0: + return self._copy_conditioning(global_conditioning) + + global_mask = complementary_global_prompt_mask( + mask_batch, + tuple(pair.mask_index for pair in plan.pairs), + regional_prompt_weight, + ) + assembled = self._with_mask( + global_conditioning, + global_mask, + mask_strength=1.0, + ) + for pair in plan.pairs: + conditioning = self._validate_conditioning( + entries[pair.conditioning_index], + input_name=(f"{input_name} regional entry {pair.conditioning_index}"), + ) + mask = regional_mask(mask_batch, pair.mask_index) + assembled.extend( + self._with_mask( + conditioning, + mask, + mask_strength=regional_prompt_weight, + ) + ) + return assembled + + def _validate_conditioning( + self, + value: object, + *, + input_name: str, + ) -> Conditioning: + """Return a structurally valid standard Comfy conditioning list.""" + + if not isinstance(value, list): + raise TypeError(f"{input_name} must be a standard CONDITIONING value.") + for index, item in enumerate(value): + if ( + not isinstance(item, list | tuple) + or len(item) != 2 + or not isinstance(item[1], dict) + ): + raise ValueError( + f"{input_name} item {index} must contain a tensor and metadata." + ) + return value + + def _copy_conditioning(self, conditioning: Conditioning) -> Conditioning: + """Copy conditioning containers and metadata without cloning tensors.""" + + return [[item[0], dict(item[1])] for item in conditioning] + + def _with_mask( + self, + conditioning: Conditioning, + mask: torch.Tensor, + *, + mask_strength: float, + ) -> Conditioning: + """Copy conditioning entries with one full-context regional mask.""" + + masked: Conditioning = [] + for item in conditioning: + metadata = dict(item[1]) + metadata["mask"] = mask + metadata["mask_strength"] = mask_strength + metadata["set_area_to_bounds"] = False + masked.append([item[0], metadata]) + return masked diff --git a/simple_syrup/services/tiled_diffusion_sampling_service.py b/simple_syrup/services/tiled_diffusion_sampling_service.py index 33d9738..fc3b177 100644 --- a/simple_syrup/services/tiled_diffusion_sampling_service.py +++ b/simple_syrup/services/tiled_diffusion_sampling_service.py @@ -41,6 +41,7 @@ class TiledDiffusionSamplingService: latent_tile_batch_size: int, preview_context: DetailPreviewContext | None = None, differential_diffusion: bool = False, + allow_full_context_masks: bool = False, ) -> Latent: """Sample a latent with the selected tiled diffusion method.""" @@ -64,6 +65,7 @@ class TiledDiffusionSamplingService: latent_tile_batch_size=latent_tile_batch_size, preview_context=preview_context, differential_diffusion=differential_diffusion, + allow_full_context_masks=allow_full_context_masks, ) if diffusion_mode == "multidiffusion": return multidiffusion_sampling.sample_multidiffusion( @@ -83,6 +85,7 @@ class TiledDiffusionSamplingService: latent_tile_batch_size=latent_tile_batch_size, preview_context=preview_context, differential_diffusion=differential_diffusion, + allow_full_context_masks=allow_full_context_masks, ) return mixture_of_diffusers_sampling.sample_mixture_of_diffusers( model=model, @@ -101,6 +104,7 @@ class TiledDiffusionSamplingService: latent_tile_batch_size=latent_tile_batch_size, preview_context=preview_context, differential_diffusion=differential_diffusion, + allow_full_context_masks=allow_full_context_masks, ) def _sample_conditioning_batch( @@ -123,6 +127,7 @@ class TiledDiffusionSamplingService: latent_tile_batch_size: int, preview_context: DetailPreviewContext | None, differential_diffusion: bool, + allow_full_context_masks: bool, ) -> Latent: """Sample latent batch items one at a time with selected conditioning.""" @@ -151,6 +156,7 @@ class TiledDiffusionSamplingService: latent_tile_batch_size=latent_tile_batch_size, preview_context=preview_context, differential_diffusion=differential_diffusion, + allow_full_context_masks=allow_full_context_masks, ) output_samples = output["samples"] if not isinstance(output_samples, torch.Tensor): diff --git a/tests/test_compose_regional_conditioning_v3_node.py b/tests/test_compose_regional_conditioning_v3_node.py new file mode 100644 index 0000000..3665b46 --- /dev/null +++ b/tests/test_compose_regional_conditioning_v3_node.py @@ -0,0 +1,79 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the standard regional-conditioning composer node.""" + +from __future__ import annotations + +from typing import ClassVar + +import torch + +from simple_syrup.nodes_v3.compose_regional_conditioning import ( + ComposeRegionalConditioningV3, +) + + +class FakeConditioningService: + """Record one composition request and return recognizable outputs.""" + + calls: ClassVar[list[dict[str, object]]] = [] + + def assemble(self, **kwargs: object) -> tuple[object, object]: + """Record arguments and return standard-conditioning stand-ins.""" + + type(self).calls.append(kwargs) + return "positive-conditioning", "negative-conditioning" + + +def test_composer_schema_outputs_native_conditioning() -> None: + """The composer exposes the same regional inputs and standard outputs.""" + + schema = ComposeRegionalConditioningV3.define_schema() + + assert schema.node_id == "SimpleSyrup.ComposeRegionalConditioning" + assert schema.display_name == "Compose Regional Conditioning" + assert schema.category == "SimpleSyrup/Conditioning" + assert [input_item.id for input_item in schema.inputs] == [ + "positive", + "negative", + "region_masks", + "regional_prompt_weight", + "region_mask_feather", + ] + assert schema.inputs[3].default == 0.5 + assert [output.io_type for output in schema.outputs] == [ + "CONDITIONING", + "CONDITIONING", + ] + + +def test_composer_delegates_to_authoritative_regional_service() -> None: + """The node adds no policy beyond its application service.""" + + original = ComposeRegionalConditioningV3.conditioning_service_class + ComposeRegionalConditioningV3.conditioning_service_class = FakeConditioningService # type: ignore[assignment] + FakeConditioningService.calls = [] + masks = torch.ones((2, 4, 4)) + try: + output = ComposeRegionalConditioningV3.execute( + positive="positive-batch", + negative="negative-batch", + region_masks=masks, + regional_prompt_weight=0.75, + region_mask_feather=3, + ) + finally: + ComposeRegionalConditioningV3.conditioning_service_class = original + + assert output == ("positive-conditioning", "negative-conditioning") + assert FakeConditioningService.calls == [ + { + "positive": "positive-batch", + "negative": "negative-batch", + "masks": masks, + "regional_prompt_weight": 0.75, + "region_mask_feather": 3, + } + ] diff --git a/tests/test_load_mask_batch_service.py b/tests/test_load_mask_batch_service.py new file mode 100644 index 0000000..bafe8e4 --- /dev/null +++ b/tests/test_load_mask_batch_service.py @@ -0,0 +1,148 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for ordered authored-mask loading orchestration.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import ClassVar + +import pytest +import torch + +from simple_syrup.runtime.mask_file_loader import MaskFileLoader +from simple_syrup.services.load_mask_batch_service import LoadMaskBatchService + + +class FakeMaskFileLoader(MaskFileLoader): + """Provide deterministic masks and fingerprints for service tests.""" + + masks: ClassVar[dict[str, torch.Tensor]] = {} + loaded: ClassVar[list[tuple[str, str]]] = [] + validated: ClassVar[list[tuple[str, str]]] = [] + + def available_files(self) -> tuple[str, ...]: + """Return deterministic selectable paths.""" + + return ("first.png", "second.png") + + def load(self, path: str, channel: str) -> torch.Tensor: + """Record and return the configured singleton mask.""" + + self.loaded.append((path, channel)) + return self.masks[path] + + def validate(self, path: str, channel: str) -> None: + """Record validation through the runtime boundary.""" + + self.validated.append((path, channel)) + + def fingerprint(self, path: str) -> str: + """Return a recognizable per-path fingerprint.""" + + return f"digest:{path}" + + +class FakeLoadMaskBatchService(LoadMaskBatchService): + """Use the fake runtime adapter in application-service tests.""" + + loader_class = FakeMaskFileLoader + + +def _service_with_masks(masks: dict[str, torch.Tensor]) -> FakeLoadMaskBatchService: + """Return a service configured with fake ordered masks.""" + + FakeMaskFileLoader.masks = masks + FakeMaskFileLoader.loaded = [] + FakeMaskFileLoader.validated = [] + return FakeLoadMaskBatchService() + + +def test_loads_one_or_many_files_in_supplied_order() -> None: + """The file sequence directly defines MASK batch order.""" + + service = _service_with_masks( + { + "b.png": torch.full((1, 2, 3), 0.8), + "a.png": torch.full((1, 2, 3), 0.2), + } + ) + + one = service.load(["a.png"], "red") + many = service.load(["b.png", "a.png"], "blue") + + assert one.shape == (1, 2, 3) + assert torch.all(one == 0.2) + assert torch.all(many[0] == 0.8) + assert torch.all(many[1] == 0.2) + assert FakeMaskFileLoader.loaded == [ + ("a.png", "red"), + ("b.png", "blue"), + ("a.png", "blue"), + ] + + +def test_rejects_empty_selection_and_dimension_mismatch() -> None: + """A batch always contains compatible one-file regions.""" + + service = _service_with_masks( + { + "small.png": torch.zeros((1, 2, 2)), + "wide.png": torch.zeros((1, 2, 3)), + } + ) + + with pytest.raises(ValueError, match="at least one mask file"): + service.load([], "alpha") + with pytest.raises(ValueError, match="identical dimensions"): + service.load(["small.png", "wide.png"], "alpha") + + +def test_fingerprint_is_sensitive_to_order_channel_and_contents() -> None: + """Caching distinguishes every workflow-relevant loader input.""" + + service = _service_with_masks({}) + + first = service.fingerprint(["a.png", "b.png"], "red") + reordered = service.fingerprint(["b.png", "a.png"], "red") + other_channel = service.fingerprint(["a.png", "b.png"], "blue") + + assert first != reordered + assert first != other_channel + + +def test_available_files_delegates_to_runtime_adapter() -> None: + """Schema choices come from the Comfy filesystem boundary.""" + + assert FakeLoadMaskBatchService().available_files() == ( + "first.png", + "second.png", + ) + + +def test_validation_preserves_selection_order() -> None: + """Every native widget value is validated in its authored order.""" + + service = _service_with_masks({}) + + service.validate(["second.png", "first.png"], "green") + + assert FakeMaskFileLoader.validated == [ + ("second.png", "green"), + ("first.png", "green"), + ] + + +@pytest.mark.parametrize("files", ["single.png", ("single.png",)]) +def test_string_and_sequence_inputs_both_mean_one_mask( + files: str | Sequence[str], +) -> None: + """API callers may serialize one selected path as a scalar or list.""" + + service = _service_with_masks({"single.png": torch.ones((1, 2, 2))}) + + result = service.load(files, "alpha") + + assert result.shape == (1, 2, 2) diff --git a/tests/test_load_mask_batch_v3_node.py b/tests/test_load_mask_batch_v3_node.py new file mode 100644 index 0000000..7014307 --- /dev/null +++ b/tests/test_load_mask_batch_v3_node.py @@ -0,0 +1,110 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Load Mask Batch Comfy v3 node.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import torch + +from simple_syrup.nodes_v3 import load_mask_batch as node_module +from simple_syrup.nodes_v3.load_mask_batch import LoadMaskBatchV3 + + +class FakeService: + """Provide deterministic loader behavior at the node boundary.""" + + loaded: ClassVar[tuple[list[str], str] | None] = None + + def available_files(self) -> tuple[str, ...]: + """Return node combo choices.""" + + return ("b.png", "a.png") + + def validate(self, files: str | list[str], channel: str) -> None: + """Accept deterministic widget values.""" + + del files, channel + + def load(self, files: list[str], channel: str) -> torch.Tensor: + """Record input order and return a two-mask batch.""" + + type(self).loaded = (files, channel) + return torch.stack([torch.zeros((2, 2)), torch.ones((2, 2))]) + + def fingerprint(self, files: list[str], channel: str) -> str: + """Return an order-visible node fingerprint.""" + + return f"{channel}:{'|'.join(files)}" + + +def test_schema_exposes_native_ordered_mask_multiselect() -> None: + """The node uses one native multi-value file list and disk uploader.""" + + original = LoadMaskBatchV3.service_class + LoadMaskBatchV3.service_class = FakeService # type: ignore[assignment] + try: + schema = LoadMaskBatchV3.define_schema() + finally: + LoadMaskBatchV3.service_class = original + + inputs = {value.id: value for value in schema.inputs} + image = inputs["image"].as_dict() + assert schema.node_id == "SimpleSyrup.LoadMaskBatch" + assert schema.display_name == "Load Mask Batch" + assert schema.has_intermediate_output is True + assert image["image_upload"] is True + assert image["allow_batch"] is True + assert image["image_folder"] == "input" + assert image["multiselect"] is True + assert image["multi_select"] == { + "placeholder": "Select one or more masks", + "chip": True, + } + assert image["default"] == [] + assert image["options"] == ["b.png", "a.png"] + assert [output.io_type for output in schema.outputs] == ["MASK"] + + +def test_execute_preserves_order_and_returns_native_preview(monkeypatch: Any) -> None: + """Execution returns one MASK batch and previews that exact tensor.""" + + previews: list[tuple[torch.Tensor, object]] = [] + + def fake_preview(mask: torch.Tensor, cls: object) -> str: + """Record the native preview payload.""" + + previews.append((mask, cls)) + return "preview" + + original = LoadMaskBatchV3.service_class + LoadMaskBatchV3.service_class = FakeService # type: ignore[assignment] + monkeypatch.setattr(node_module._comfy_ui, "PreviewMask", fake_preview) + try: + output = LoadMaskBatchV3.execute(["b.png", "a.png"], "green") + fingerprint = LoadMaskBatchV3.fingerprint_inputs(["b.png", "a.png"], "green") + finally: + LoadMaskBatchV3.service_class = original + + assert FakeService.loaded == (["b.png", "a.png"], "green") + assert output.result is not None + mask_batch = output.result[0] + assert mask_batch.shape == (2, 2, 2) + assert previews == [(mask_batch, LoadMaskBatchV3)] + assert output.ui == "preview" + assert fingerprint == "green:b.png|a.png" + + +def test_validate_inputs_accepts_native_scalar_or_batch_values() -> None: + """The regular native combo may serialize one path or an ordered path list.""" + + original = LoadMaskBatchV3.service_class + LoadMaskBatchV3.service_class = FakeService # type: ignore[assignment] + try: + assert LoadMaskBatchV3.validate_inputs("a.png", "alpha") is True + assert LoadMaskBatchV3.validate_inputs(["b.png", "a.png"], "red") is True + finally: + LoadMaskBatchV3.service_class = original diff --git a/tests/test_mask_batch_preview_routes.py b/tests/test_mask_batch_preview_routes.py new file mode 100644 index 0000000..f1db509 --- /dev/null +++ b/tests/test_mask_batch_preview_routes.py @@ -0,0 +1,353 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for channel-accurate authored-mask preview routes.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable, Sequence +from importlib import import_module +from pathlib import Path +from typing import cast + +import pytest +from aiohttp import web +from PIL import Image + +import simple_syrup.runtime.mask_batch_preview_routes as preview_routes +from simple_syrup.runtime.mask_batch_preview_routes import ( + MASK_BATCH_PREVIEW_ROUTE, + Handler, + MaskBatchLoaderProtocol, + MaskBatchPreviewHandlers, + MaskBatchPreviewRendererProtocol, + NativeMaskBatchPreviewRenderer, + PromptServerProtocol, + register_mask_batch_preview_routes, +) +from simple_syrup.services.load_mask_batch_service import LoadMaskBatchService + + +class FakeRoutes: + """Route table double that records decorated POST handlers.""" + + def __init__(self) -> None: + """Create an empty fake route table.""" + + self.post_handlers: dict[str, Handler] = {} + + def post(self, path: str) -> Callable[[Handler], Handler]: + """Record a POST route handler.""" + + def decorator(handler: Handler) -> Handler: + self.post_handlers[path] = handler + return handler + + return decorator + + +class FakePromptServer: + """PromptServer double exposing a route table.""" + + def __init__(self) -> None: + """Create a fake PromptServer.""" + + self.routes = FakeRoutes() + + +class FakeRequest: + """Request double with injectable JSON body behavior.""" + + def __init__(self, payload: object | BaseException) -> None: + """Create a request that returns or raises from `json()`.""" + + self._payload = payload + + async def json(self) -> object: + """Return the configured JSON payload.""" + + if isinstance(self._payload, BaseException): + raise self._payload + return self._payload + + +class FakeMaskBatchLoader: + """Record preview loads and return recognizable mask objects.""" + + def __init__(self, failure: ValueError | None = None) -> None: + """Create a loader with an optional validation failure.""" + + self.failure = failure + self.loaded: list[tuple[tuple[str, ...], str]] = [] + self.masks = (object(), object()) + + def load_each(self, files: Sequence[str], channel: str) -> Sequence[object]: + """Record the ordered request and return configured masks.""" + + if self.failure is not None: + raise self.failure + self.loaded.append((tuple(files), channel)) + return self.masks + + +class FakeMaskBatchPreviewRenderer: + """Return a recognizable native-preview response.""" + + def __init__(self) -> None: + """Create a renderer that records the loaded batch.""" + + self.rendered: list[tuple[object, ...]] = [] + self.payload: dict[str, object] = { + "images": [ + { + "filename": "ComfyUI_temp_mask.png", + "subfolder": "", + "type": "temp", + } + ], + "animated": [False], + } + + def render(self, masks: Sequence[object]) -> dict[str, object]: + """Record and return the native preview payload.""" + + self.rendered.append(tuple(masks)) + return self.payload + + +def test_preview_route_loads_selected_channel_and_returns_native_output() -> None: + """The preview uses the same ordered files and channel as node execution.""" + + prompt_server = FakePromptServer() + loader = FakeMaskBatchLoader() + renderer = FakeMaskBatchPreviewRenderer() + register_fake_routes(prompt_server, loader, renderer) + + response = asyncio.run( + prompt_server.routes.post_handlers[MASK_BATCH_PREVIEW_ROUTE]( + FakeRequest( + { + "files": ["characters/left.png", "characters/right.png"], + "channel": "green", + } + ) + ) + ) + + assert response.status == 200 + assert json.loads(response_text(response)) == renderer.payload + assert loader.loaded == [(("characters/left.png", "characters/right.png"), "green")] + assert renderer.rendered == [loader.masks] + + +def test_preview_route_uses_real_native_loader_and_renderer_on_synthetic_masks( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Synthetic masks preview through the same native path as node execution.""" + + input_directory = tmp_path / "input" + preview_directory = tmp_path / "preview" + input_directory.mkdir() + preview_directory.mkdir() + paths = { + "left.png": input_directory / "left.png", + "right.png": input_directory / "right.png", + } + Image.new("RGBA", (8, 6), color=(255, 255, 255, 0)).save(paths["left.png"]) + Image.new("RGBA", (8, 6), color=(0, 0, 0, 255)).save(paths["right.png"]) + + folder_paths = import_module("folder_paths") + monkeypatch.setattr( + folder_paths, + "exists_annotated_filepath", + lambda value: value in paths, + ) + monkeypatch.setattr( + folder_paths, + "get_annotated_filepath", + lambda value: str(paths[value]), + ) + monkeypatch.setattr( + folder_paths, + "get_temp_directory", + lambda: str(preview_directory), + ) + + handler = MaskBatchPreviewHandlers( + LoadMaskBatchService(), + NativeMaskBatchPreviewRenderer(), + ) + response = asyncio.run( + handler.post_preview( + FakeRequest({"files": ["left.png", "right.png"], "channel": "alpha"}) + ) + ) + + payload = json.loads(response_text(response)) + assert response.status == 200 + assert len(payload["images"]) == 2 + assert payload["animated"] == [False] + for image in payload["images"]: + assert image["type"] == "temp" + preview_path = preview_directory / image["subfolder"] / image["filename"] + with Image.open(preview_path) as preview: + assert preview.size == (8, 6) + + +def test_preview_route_renders_mixed_dimensions_independently( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preview every selected mask even when execution cannot form one batch.""" + + input_directory = tmp_path / "input" + preview_directory = tmp_path / "preview" + input_directory.mkdir() + preview_directory.mkdir() + paths = { + "small.png": input_directory / "small.png", + "large.png": input_directory / "large.png", + } + Image.new("RGBA", (8, 6), color=(255, 255, 255, 0)).save(paths["small.png"]) + Image.new("RGBA", (12, 10), color=(0, 0, 0, 255)).save(paths["large.png"]) + + folder_paths = import_module("folder_paths") + monkeypatch.setattr( + folder_paths, + "exists_annotated_filepath", + lambda value: value in paths, + ) + monkeypatch.setattr( + folder_paths, + "get_annotated_filepath", + lambda value: str(paths[value]), + ) + monkeypatch.setattr( + folder_paths, + "get_temp_directory", + lambda: str(preview_directory), + ) + + service = LoadMaskBatchService() + handler = MaskBatchPreviewHandlers( + service, + NativeMaskBatchPreviewRenderer(), + ) + response = asyncio.run( + handler.post_preview( + FakeRequest({"files": ["small.png", "large.png"], "channel": "red"}) + ) + ) + + payload = json.loads(response_text(response)) + assert response.status == 200 + assert len(payload["images"]) == 2 + preview_sizes = [] + for image in payload["images"]: + preview_path = preview_directory / image["subfolder"] / image["filename"] + with Image.open(preview_path) as preview: + preview_sizes.append(preview.size) + assert preview_sizes == [(8, 6), (12, 10)] + with pytest.raises(ValueError, match="identical dimensions"): + service.load(["small.png", "large.png"], "red") + + +@pytest.mark.parametrize( + "payload, message", + [ + ({"channel": "alpha"}, "files"), + ({"files": [], "channel": "alpha"}, "at least one"), + ({"files": ["mask.png"], "channel": 1}, "channel"), + ], +) +def test_preview_route_rejects_malformed_payloads( + payload: object, + message: str, +) -> None: + """Malformed preview requests fail before filesystem loading.""" + + prompt_server = FakePromptServer() + loader = FakeMaskBatchLoader() + register_fake_routes(prompt_server, loader, FakeMaskBatchPreviewRenderer()) + + response = asyncio.run( + prompt_server.routes.post_handlers[MASK_BATCH_PREVIEW_ROUTE]( + FakeRequest(payload) + ) + ) + + assert response.status == 400 + assert message in response_text(response) + assert loader.loaded == [] + + +def test_preview_route_surfaces_loader_validation_errors() -> None: + """Native path, channel, and shape errors remain actionable to the client.""" + + prompt_server = FakePromptServer() + loader = FakeMaskBatchLoader(ValueError("mask dimensions do not match")) + register_fake_routes(prompt_server, loader, FakeMaskBatchPreviewRenderer()) + + response = asyncio.run( + prompt_server.routes.post_handlers[MASK_BATCH_PREVIEW_ROUTE]( + FakeRequest({"files": ["mask.png"], "channel": "alpha"}) + ) + ) + + assert response.status == 400 + assert json.loads(response_text(response)) == { + "error": "mask dimensions do not match" + } + + +def test_preview_route_rejects_invalid_json() -> None: + """Bodies that cannot be decoded never reach the mask loader.""" + + prompt_server = FakePromptServer() + loader = FakeMaskBatchLoader() + register_fake_routes(prompt_server, loader, FakeMaskBatchPreviewRenderer()) + + response = asyncio.run( + prompt_server.routes.post_handlers[MASK_BATCH_PREVIEW_ROUTE]( + FakeRequest(ValueError("bad json")) + ) + ) + + assert response.status == 400 + assert "valid JSON" in response_text(response) + assert loader.loaded == [] + + +def test_route_registration_is_import_safe_without_prompt_server( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing Comfy PromptServer does not import or initialize mask services.""" + + monkeypatch.setattr(preview_routes, "_prompt_server_instance", lambda: None) + + assert register_mask_batch_preview_routes(prompt_server=None) is False + + +def register_fake_routes( + prompt_server: FakePromptServer, + loader: FakeMaskBatchLoader, + renderer: FakeMaskBatchPreviewRenderer, +) -> bool: + """Register preview routes against structurally typed test doubles.""" + + return register_mask_batch_preview_routes( + loader=cast(MaskBatchLoaderProtocol, loader), + renderer=cast(MaskBatchPreviewRendererProtocol, renderer), + prompt_server=cast(PromptServerProtocol, prompt_server), + ) + + +def response_text(response: web.Response) -> str: + """Return response text after asserting aiohttp populated it.""" + + assert response.text is not None + return response.text diff --git a/tests/test_mask_file_loader.py b/tests/test_mask_file_loader.py new file mode 100644 index 0000000..256e418 --- /dev/null +++ b/tests/test_mask_file_loader.py @@ -0,0 +1,127 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Comfy authored-mask file adapter.""" + +from __future__ import annotations + +from importlib import import_module +from pathlib import Path + +import pytest +import torch +from PIL import Image + +from simple_syrup.runtime.mask_file_loader import MaskFileLoader + + +def _patch_path( + monkeypatch: pytest.MonkeyPatch, + path: Path, + *, + exists: bool = True, +) -> None: + """Route Comfy annotated-path helpers to one temporary file.""" + + folder_paths = import_module("folder_paths") + monkeypatch.setattr(import_module("nodes"), "folder_paths", folder_paths) + + monkeypatch.setattr(folder_paths, "exists_annotated_filepath", lambda value: exists) + monkeypatch.setattr(folder_paths, "get_annotated_filepath", lambda value: str(path)) + + +def test_load_uses_native_mask_loader_and_validated_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Static files are decoded through Comfy's LoadImageMask behavior.""" + + path = tmp_path / "mask.png" + Image.new("RGB", (3, 2), color=(255, 0, 0)).save(path) + _patch_path(monkeypatch, path) + calls: list[tuple[str, str]] = [] + + class FakeNativeLoader: + """Return one recognizable native mask.""" + + @classmethod + def VALIDATE_INPUTS(cls, value: str) -> bool: + """Accept the annotated path through the native contract.""" + + del cls, value + return True + + def load_image_mask(self, value: str, channel: str) -> tuple[torch.Tensor]: + """Record native loader arguments.""" + + calls.append((value, channel)) + return (torch.full((1, 2, 3), 0.75),) + + nodes = import_module("nodes") + + monkeypatch.setattr(nodes, "LoadImageMask", FakeNativeLoader) + + result = MaskFileLoader().load("mask.png", "red") + + assert calls == [("mask.png", "red")] + assert torch.all(result == 0.75) + + +def test_available_files_uses_native_mask_loader_choices( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The node's file combo is sourced from native LoadImageMask inputs.""" + + class FakeNativeLoader: + """Expose recognizable native widget choices.""" + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[list[str], dict[str, bool]]]]: + """Return the native image-upload declaration shape.""" + + del cls + return {"required": {"image": (["z.png", "a.png"], {"image_upload": True})}} + + monkeypatch.setattr(import_module("nodes"), "LoadImageMask", FakeNativeLoader) + + assert MaskFileLoader().available_files() == ("z.png", "a.png") + + +def test_rejects_missing_multiframe_and_invalid_channel( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Files cannot silently add regions or escape native channel semantics.""" + + path = tmp_path / "animated.gif" + first = Image.new("L", (2, 2), color=0) + second = Image.new("L", (2, 2), color=255) + first.save(path, save_all=True, append_images=[second]) + _patch_path(monkeypatch, path) + loader = MaskFileLoader() + + with pytest.raises(ValueError, match="contains 2 frames"): + loader.load("animated.gif", "red") + with pytest.raises(ValueError, match="mask channel must be one of"): + loader.load("animated.gif", "luminance") + + _patch_path(monkeypatch, path, exists=False) + with pytest.raises(ValueError, match="does not exist"): + loader.fingerprint("missing.png") + + +def test_fingerprint_reads_validated_file_contents( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Content changes invalidate the loader node cache.""" + + path = tmp_path / "mask.png" + path.write_bytes(b"first") + _patch_path(monkeypatch, path) + loader = MaskFileLoader() + first = loader.fingerprint("mask.png") + path.write_bytes(b"second") + + assert loader.fingerprint("mask.png") != first diff --git a/tests/test_native_hooked_regional_conditioning.py b/tests/test_native_hooked_regional_conditioning.py new file mode 100644 index 0000000..5c68c2b --- /dev/null +++ b/tests/test_native_hooked_regional_conditioning.py @@ -0,0 +1,195 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Integration tests for native Comfy hook-aware regional evaluation.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import comfy.conds +import comfy.hooks +import comfy.sampler_helpers +import comfy.samplers +import pytest +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.tiled_diffusion import build_tiled_diffusion_plan +from simple_syrup.runtime.mixture_of_diffusers_sampling import ( + MixtureOfDiffusersModelWrapper, +) +from simple_syrup.runtime.multidiffusion_sampling import MultiDiffusionModelWrapper +from simple_syrup.services.regional_conditioning_service import ( + RegionalConditioningService, +) + +ModelWrapperFactory = Callable[[], object] + + +class FakePatcher: + """Record hook preparation and activation performed by Comfy's sampler.""" + + def __init__(self) -> None: + """Initialize hook observations for one model evaluation.""" + + self.prepared: list[comfy.hooks.HookGroup] = [] + self.applied: list[comfy.hooks.HookGroup | None] = [] + self.active: comfy.hooks.HookGroup | None = None + + def prepare_hook_patches_current_keyframe( + self, + timestep: torch.Tensor, + hooks: comfy.hooks.HookGroup, + model_options: dict[str, Any], + ) -> None: + """Record the hook group prepared for the current sigma.""" + + del timestep, model_options + self.prepared.append(hooks) + + def prepare_state( + self, + timestep: torch.Tensor, + model_options: dict[str, Any], + ) -> None: + """Accept Comfy's per-step patcher state preparation.""" + + del timestep, model_options + + def get_free_memory(self, device: torch.device) -> float: + """Return ample deterministic capacity for conditioning batches.""" + + del device + return 1.0e12 + + def apply_hooks( + self, + *, + hooks: comfy.hooks.HookGroup | None, + ) -> dict[str, Any]: + """Record the hook group activated before the model call.""" + + self.applied.append(hooks) + self.active = hooks + return {} + + +class FakeModel: + """Provide the minimal model surface used by native conditioning evaluation.""" + + def __init__(self) -> None: + """Create a model with an observable hook-aware patcher.""" + + self.current_patcher = FakePatcher() + self.model_calls: list[comfy.hooks.HookGroup | None] = [] + + def memory_required( + self, + input_shape: list[int], + *, + cond_shapes: dict[str, list[list[int]]], + ) -> float: + """Return a fixed estimate so Comfy can select a batch size.""" + + del input_shape, cond_shapes + return 1.0 + + def apply_model( + self, + input_x: torch.Tensor, + timestep: torch.Tensor, + **conditioning: Any, + ) -> torch.Tensor: + """Record the active hook group and return a deterministic prediction.""" + + del timestep, conditioning + self.model_calls.append(self.current_patcher.active) + return torch.ones_like(input_x) + + +@pytest.mark.parametrize( + "wrapper_factory", + [ + None, + lambda: MultiDiffusionModelWrapper( + plan=build_tiled_diffusion_plan( + latent_width=8, + latent_height=8, + tile_width=4, + tile_height=4, + overlap=1, + tile_batch_size=2, + ), + existing_wrapper=None, + ), + lambda: MixtureOfDiffusersModelWrapper( + plan=build_tiled_diffusion_plan( + latent_width=8, + latent_height=8, + tile_width=4, + tile_height=4, + overlap=1, + tile_batch_size=2, + ), + existing_wrapper=None, + ), + ], + ids=["native", "multidiffusion", "mixture-of-diffusers"], +) +def test_native_sampler_activates_hooks_from_masked_regional_conditioning( + wrapper_factory: ModelWrapperFactory | None, +) -> None: + """Comfy activates each preserved hook group through direct and tiled paths.""" + + global_hooks = comfy.hooks.HookGroup() + regional_hooks = comfy.hooks.HookGroup() + assembled, _ = RegionalConditioningService().assemble( + positive=ConditioningBatch( + ( + _conditioning(global_hooks), + _conditioning(regional_hooks), + ) + ), + negative=_conditioning(None), + masks=torch.ones((1, 8, 8)), + regional_prompt_weight=0.5, + region_mask_feather=0, + ) + converted = comfy.sampler_helpers.convert_cond(assembled) + for conditioning in converted: + cross_attn = conditioning.pop("cross_attn") + conditioning["model_conds"] = { + "c_crossattn": comfy.conds.CONDCrossAttn(cross_attn) + } + + model = FakeModel() + model_options: dict[str, Any] = {} + if wrapper_factory is not None: + model_options["model_function_wrapper"] = wrapper_factory() + + outputs = comfy.samplers.calc_cond_batch( + model, + [converted], + torch.zeros((1, 1, 8, 8)), + torch.ones((1,)), + model_options, + ) + + assert len(outputs) == 1 + assert set(model.current_patcher.prepared) == {global_hooks, regional_hooks} + assert set(model.current_patcher.applied) == {global_hooks, regional_hooks} + assert set(model.model_calls) == {global_hooks, regional_hooks} + + +def _conditioning( + hooks: comfy.hooks.HookGroup | None, +) -> list[list[object]]: + """Return one raw Comfy conditioning entry with optional hooks.""" + + metadata: dict[str, object] = {} + if hooks is not None: + metadata["hooks"] = hooks + return [[torch.ones((1, 2, 3)), metadata]] diff --git a/tests/test_node_tooltips.py b/tests/test_node_tooltips.py index 8150e48..2592db2 100644 --- a/tests/test_node_tooltips.py +++ b/tests/test_node_tooltips.py @@ -7,6 +7,7 @@ from __future__ import annotations import sys +from pathlib import Path from types import ModuleType from typing import Any, Protocol @@ -44,6 +45,21 @@ class _FakeFolderPaths(ModuleType): return self._files.get(folder_name, []) + def get_input_directory(self) -> str: + """Return an existing directory for native image-loader discovery.""" + + return str(Path(__file__).parent) + + def filter_files_content_types( + self, + files: list[str], + content_types: list[str], + ) -> list[str]: + """Return no images from the test directory.""" + + del files, content_types + return [] + def test_v3_nodes_provide_tooltip_metadata(monkeypatch: pytest.MonkeyPatch) -> None: """All supported v3 schemas expose descriptions and field-level help.""" @@ -92,6 +108,12 @@ def test_high_impact_tooltips_explain_direction_and_units( assert "seams" in tiled_inputs["latent_tile_overlap"].tooltip.lower() assert "memory" in tiled_inputs["latent_tile_batch_size"].tooltip.lower() + regional_inputs = _inputs_by_id(schemas["SimpleSyrup.KSamplerPromptByRegion"]) + regional_weight_tooltip = regional_inputs["regional_prompt_weight"].tooltip.lower() + assert "0" in regional_weight_tooltip + assert "1" in regional_weight_tooltip + assert "overlaps" in regional_weight_tooltip + def _inputs_by_id(schema: Any) -> dict[str, Any]: """Return schema inputs keyed by id.""" diff --git a/tests/test_prompt_control_batch_graph.py b/tests/test_prompt_control_batch_graph.py index d631134..f9ace8d 100644 --- a/tests/test_prompt_control_batch_graph.py +++ b/tests/test_prompt_control_batch_graph.py @@ -86,6 +86,38 @@ def test_prompt_control_batch_graph_builds_pack_chain_for_multiple_chunks( assert output.args[1] == ["BATCH.0.5.2", 0] +def test_prompt_control_batch_graph_attaches_segment_local_lora_hooks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The clip-only encoder shares each aligned hook plan across both sides.""" + + calls = _install_fake_prompt_control(monkeypatch) + graph_utils = import_module("comfy_execution.graph_utils") + graph_utils.GraphBuilder.set_default_prefix("HOOKS", 0, 0) + + output = PromptControlBatchGraphBuilder().build( + clip=[0, 0], + positive_prompt="face [SEP] hair ", + negative_prompt="blur [SEP] noise", + separator="[SEP]", + ) + + assert output.expand is not None + hook_nodes = [ + node + for node in output.expand.values() + if node["class_type"] == "PCLoraHooksFromText" + ] + assert [node["inputs"]["text"] for node in hook_nodes] == [ + "\n", + "", + ] + assert [call["text"] for call in calls] == ["face ", "hair ", "blur ", "noise"] + assert calls[0]["clip"] == calls[2]["clip"] + assert calls[1]["clip"] == calls[3]["clip"] + assert calls[0]["clip"] != calls[1]["clip"] + + def test_prompt_control_batch_graph_reports_missing_prompt_control( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -97,7 +129,7 @@ def test_prompt_control_batch_graph_reports_missing_prompt_control( return import_module(name) monkeypatch.setattr( - "simple_syrup.runtime.prompt_control_batch_graph.import_module", + "simple_syrup.runtime.prompt_control_graph_adapter.import_module", fake_import_module, ) @@ -111,11 +143,14 @@ def test_prompt_control_batch_graph_reports_missing_prompt_control( assert PROMPT_CONTROL_MISSING_MESSAGE.startswith("Encode Prompt Batch") -def _install_fake_prompt_control(monkeypatch: pytest.MonkeyPatch) -> None: +def _install_fake_prompt_control( + monkeypatch: pytest.MonkeyPatch, +) -> list[dict[str, Any]]: """Install a small Prompt Control lazy-node double for graph tests.""" prompt_control = ModuleType("prompt_control") nodes_lazy = ModuleType("prompt_control.nodes_lazy") + calls: list[dict[str, Any]] = [] class FakePCLazyTextEncodeAdvanced: """Graph-expanding stand-in for Prompt Control's lazy text encoder.""" @@ -132,6 +167,7 @@ def _install_fake_prompt_control(monkeypatch: pytest.MonkeyPatch) -> None: """Return one lazy text encode node output.""" del tags, start, end, num_steps + calls.append({"clip": clip, "text": text}) graph_utils = import_module("comfy_execution.graph_utils") io = import_module("comfy_api.latest").io graph = graph_utils.GraphBuilder() @@ -146,3 +182,4 @@ def _install_fake_prompt_control(monkeypatch: pytest.MonkeyPatch) -> None: cast(Any, prompt_control).nodes_lazy = nodes_lazy monkeypatch.setitem(sys.modules, "prompt_control", prompt_control) monkeypatch.setitem(sys.modules, "prompt_control.nodes_lazy", nodes_lazy) + return calls diff --git a/tests/test_prompt_control_prompt.py b/tests/test_prompt_control_prompt.py index af1ccaf..d72a4a8 100644 --- a/tests/test_prompt_control_prompt.py +++ b/tests/test_prompt_control_prompt.py @@ -38,8 +38,8 @@ def test_extract_lora_tags_returns_angle_tags_joined_with_newlines() -> None: assert extract_lora_tags(text) == "\n" -def test_prepare_prompt_side_splits_ordered_chunks_and_aggregates_loras() -> None: - """Separator-delimited chunks preserve order and collect all tags.""" +def test_prepare_prompt_side_preserves_ordered_segment_local_loras() -> None: + """Separator-delimited chunks retain their own tags without aggregation.""" side = prepare_prompt_side( "face [SEP] hair ", @@ -51,7 +51,6 @@ def test_prepare_prompt_side_splits_ordered_chunks_and_aggregates_loras() -> Non "", "", ] - assert side.lora_tags == "\n" def test_prepare_prompt_side_preserves_empty_chunks() -> None: @@ -61,7 +60,6 @@ def test_prepare_prompt_side_preserves_empty_chunks() -> None: assert [chunk.text for chunk in side.chunks] == ["face", ""] assert [chunk.lora_tags for chunk in side.chunks] == ["", ""] - assert side.lora_tags == "" def test_prepare_prompt_side_rejects_empty_separator() -> None: diff --git a/tests/test_prompt_control_schedule_encode_graph.py b/tests/test_prompt_control_schedule_encode_graph.py index 91f5f8c..883e630 100644 --- a/tests/test_prompt_control_schedule_encode_graph.py +++ b/tests/test_prompt_control_schedule_encode_graph.py @@ -88,22 +88,41 @@ def test_schedule_encode_graph_packs_only_multichunk_sides( ] -def test_schedule_encode_graph_collects_loras_from_all_chunks( +def test_schedule_encode_graph_keeps_loras_local_to_aligned_segments( monkeypatch: pytest.MonkeyPatch, ) -> None: - """LoRA scheduling sees all tags from every separator-delimited chunk.""" + """Each SEP index receives one hook plan shared by both prompt sides.""" calls = _install_fake_prompt_control(monkeypatch) - PromptControlScheduleEncodeGraphBuilder().build( + output = PromptControlScheduleEncodeGraphBuilder().build( model=["model", 0], clip=["clip", 0], positive_prompt="face [SEP] hair ", - negative_prompt="blur [SEP] noise ", + negative_prompt="blur [SEP] noise ", ) - assert calls["lora"][0]["text"] == "\n" - assert calls["lora"][1]["text"] == "\n" + assert output.args[0] == ["model", 0] + assert calls["lora"] == [] + assert output.expand is not None + hook_nodes = [ + node + for node in output.expand.values() + if node["class_type"] == "PCLoraHooksFromText" + ] + assert [node["inputs"]["text"] for node in hook_nodes] == [ + "\n", + "\n", + ] + clip_nodes = [ + node for node in output.expand.values() if node["class_type"] == "SetClipHooks" + ] + assert len(clip_nodes) == 2 + assert all(node["inputs"]["apply_to_conds"] is True for node in clip_nodes) + assert all(node["inputs"]["schedule_clip"] is True for node in clip_nodes) + assert calls["encode"][0]["clip"] == calls["encode"][2]["clip"] + assert calls["encode"][1]["clip"] == calls["encode"][3]["clip"] + assert calls["encode"][0]["clip"] != calls["encode"][1]["clip"] def test_schedule_encode_graph_reports_duplicate_expand_ids( @@ -133,7 +152,7 @@ def test_schedule_encode_graph_reports_missing_prompt_control( return import_module(name) monkeypatch.setattr( - "simple_syrup.runtime.prompt_control_schedule_encode_graph.import_module", + "simple_syrup.runtime.prompt_control_graph_adapter.import_module", fake_import_module, ) diff --git a/tests/test_prompt_control_segment_planning_service.py b/tests/test_prompt_control_segment_planning_service.py new file mode 100644 index 0000000..69903fd --- /dev/null +++ b/tests/test_prompt_control_segment_planning_service.py @@ -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 + +"""Tests for Prompt-Control SEP segment and hook planning.""" + +from __future__ import annotations + +from simple_syrup.services.prompt_control_segment_planning_service import ( + PromptControlSegmentPlanningService, +) + + +def test_planner_combines_lora_tags_only_at_aligned_indexes() -> None: + """Positive and negative LoRAs share a plan only within one SEP position.""" + + plan = PromptControlSegmentPlanningService().prepare( + positive_prompt=( + "global [SEP] " + "left [SEP] right" + ), + negative_prompt=( + "bad [SEP] worse " + ), + separator="[SEP]", + ) + + assert plan.is_batched is True + assert [chunk.text for chunk in plan.positive.chunks] == [ + "global ", + "left ", + "right", + ] + assert [hook.lora_tags for hook in plan.hooks] == [ + "\n", + "\n", + "", + ] + + +def test_planner_preserves_explicit_empties_without_padding_shorter_side() -> None: + """Empty chunks remain positional while unequal side lengths remain unequal.""" + + plan = PromptControlSegmentPlanningService().prepare( + positive_prompt="global [SEP] [SEP] right ", + negative_prompt="negative", + separator="[SEP]", + ) + + assert [chunk.text for chunk in plan.positive.chunks] == [ + "global", + "", + "right ", + ] + assert [chunk.text for chunk in plan.negative.chunks] == ["negative"] + assert [hook.lora_tags for hook in plan.hooks] == ["", "", ""] + + +def test_planner_marks_single_segment_prompts_as_unbatched() -> None: + """No-SEP prompts retain the compatibility path used by the scheduler.""" + + plan = PromptControlSegmentPlanningService().prepare( + positive_prompt="portrait ", + negative_prompt="blur", + separator="[SEP]", + ) + + assert plan.is_batched is False + assert plan.hooks[0].lora_tags == "" diff --git a/tests/test_regional_conditioning_service.py b/tests/test_regional_conditioning_service.py new file mode 100644 index 0000000..08b8295 --- /dev/null +++ b/tests/test_regional_conditioning_service.py @@ -0,0 +1,222 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for shared regional conditioning assembly.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.services.regional_conditioning_service import ( + RegionalConditioningService, +) + + +def _conditioning(name: str) -> list[list[object]]: + """Return recognizable standard Comfy conditioning.""" + + return [[name, {"source": name}]] + + +def test_normal_conditioning_remains_global_only() -> None: + """Normal conditioning is copied and broadcast globally.""" + + positive = _conditioning("global positive") + negative = _conditioning("global negative") + + assembled_positive, assembled_negative = RegionalConditioningService().assemble( + positive=positive, + negative=negative, + masks=torch.ones((2, 4, 4)), + regional_prompt_weight=0.5, + region_mask_feather=0, + ) + + assert assembled_positive == positive + assert assembled_negative == negative + assert assembled_positive is not positive + assert assembled_positive[0][1] is not positive[0][1] + + +def test_batches_pair_global_and_regions_independently() -> None: + """Positive and negative regional counts may differ without fallback reuse.""" + + masks = torch.stack( + [torch.zeros((3, 3)), torch.ones((3, 3)), torch.full((3, 3), 0.5)] + ) + positive = ConditioningBatch( + ( + _conditioning("positive global"), + _conditioning("positive region 0"), + _conditioning("positive region 1"), + ) + ) + negative = ConditioningBatch( + (_conditioning("negative global"), _conditioning("negative region 0")) + ) + + assembled_positive, assembled_negative = RegionalConditioningService().assemble( + positive=positive, + negative=negative, + masks=masks, + regional_prompt_weight=0.75, + region_mask_feather=0, + ) + + assert [item[0] for item in assembled_positive] == [ + "positive global", + "positive region 0", + "positive region 1", + ] + assert [item[0] for item in assembled_negative] == [ + "negative global", + "negative region 0", + ] + assert torch.equal( + assembled_positive[0][1]["mask"], + torch.full((1, 3, 3), 0.25), + ) + assert torch.equal( + assembled_negative[0][1]["mask"], + torch.ones((1, 3, 3)), + ) + assert assembled_positive[0][1]["mask_strength"] == 1.0 + assert assembled_negative[0][1]["mask_strength"] == 1.0 + assert torch.equal(assembled_positive[1][1]["mask"], masks[0:1]) + assert torch.equal(assembled_positive[2][1]["mask"], masks[1:2]) + assert torch.equal(assembled_negative[1][1]["mask"], masks[0:1]) + assert assembled_positive[1][1]["mask_strength"] == 0.75 + assert assembled_positive[2][1]["mask_strength"] == 0.75 + assert assembled_negative[1][1]["mask_strength"] == 0.75 + assert assembled_positive[1][1]["set_area_to_bounds"] is False + + +def test_excess_positive_or_negative_regions_fail() -> None: + """Either conditioning side rejects regional entries without masks.""" + + service = RegionalConditioningService() + masks = torch.ones((1, 2, 2)) + excessive = ConditioningBatch( + (_conditioning("global"), _conditioning("one"), _conditioning("two")) + ) + + with pytest.raises(ValueError, match="positive conditioning contains 2"): + service.assemble( + positive=excessive, + negative=_conditioning("negative"), + masks=masks, + regional_prompt_weight=0.5, + region_mask_feather=0, + ) + with pytest.raises(ValueError, match="negative conditioning contains 2"): + service.assemble( + positive=_conditioning("positive"), + negative=excessive, + masks=masks, + regional_prompt_weight=0.5, + region_mask_feather=0, + ) + + +def test_feathering_preserves_inputs_and_softens_regional_copy() -> None: + """Optional feathering changes attached masks without mutating the input.""" + + masks = torch.zeros((1, 9, 9)) + masks[:, 3:6, 3:6] = 1.0 + original = masks.clone() + + positive, _ = RegionalConditioningService().assemble( + positive=ConditioningBatch((_conditioning("global"), _conditioning("region"))), + negative=_conditioning("negative"), + masks=masks, + regional_prompt_weight=0.5, + region_mask_feather=2, + ) + + attached = positive[1][1]["mask"] + assert isinstance(attached, torch.Tensor) + assert torch.equal(masks, original) + assert not torch.equal(attached, original) + assert bool(torch.any((attached > 0.0) & (attached < 1.0))) + + +def test_mask_composition_preserves_segment_lora_hook_metadata() -> None: + """Global and regional hook groups survive standard mask composition.""" + + global_hooks = object() + regional_hooks = object() + positive = ConditioningBatch( + ( + [["global", {"hooks": global_hooks, "other": "global metadata"}]], + [["region", {"hooks": regional_hooks, "other": "region metadata"}]], + ) + ) + + assembled, _ = RegionalConditioningService().assemble( + positive=positive, + negative=_conditioning("negative"), + masks=torch.ones((1, 3, 3)), + regional_prompt_weight=0.5, + region_mask_feather=0, + ) + + assert assembled[0][1]["hooks"] is global_hooks + assert assembled[0][1]["other"] == "global metadata" + assert assembled[1][1]["hooks"] is regional_hooks + assert assembled[1][1]["other"] == "region metadata" + + +def test_zero_regional_prompt_weight_returns_only_unchanged_global_entries() -> None: + """The zero endpoint disables regional conditioning completely.""" + + positive, negative = RegionalConditioningService().assemble( + positive=ConditioningBatch((_conditioning("global"), _conditioning("region"))), + negative=ConditioningBatch( + (_conditioning("global negative"), _conditioning("region negative")) + ), + masks=torch.ones((1, 2, 2)), + regional_prompt_weight=0.0, + region_mask_feather=0, + ) + + assert positive == _conditioning("global") + assert negative == _conditioning("global negative") + assert "mask" not in positive[0][1] + assert "mask" not in negative[0][1] + + +def test_full_regional_prompt_weight_complements_global_inside_mask() -> None: + """The one endpoint removes global influence only inside solid coverage.""" + + mask = torch.tensor([[[1.0, 0.0], [0.5, 0.0]]]) + positive, _ = RegionalConditioningService().assemble( + positive=ConditioningBatch((_conditioning("global"), _conditioning("region"))), + negative=_conditioning("negative"), + masks=mask, + regional_prompt_weight=1.0, + region_mask_feather=0, + ) + + assert torch.equal(positive[0][1]["mask"], 1.0 - mask) + assert positive[0][1]["mask_strength"] == 1.0 + assert torch.equal(positive[1][1]["mask"], mask) + assert positive[1][1]["mask_strength"] == 1.0 + + +@pytest.mark.parametrize("weight", [-0.01, 1.01, float("nan")]) +def test_invalid_regional_prompt_weight_fails_before_mask_processing( + weight: float, +) -> None: + """The service rejects invalid influence before touching mask inputs.""" + + with pytest.raises(ValueError, match="regional_prompt_weight"): + RegionalConditioningService().assemble( + positive=_conditioning("positive"), + negative=_conditioning("negative"), + masks=object(), + regional_prompt_weight=weight, + region_mask_feather=0, + ) diff --git a/tests/test_regional_ksampler_v3_nodes.py b/tests/test_regional_ksampler_v3_nodes.py new file mode 100644 index 0000000..a1bf4c5 --- /dev/null +++ b/tests/test_regional_ksampler_v3_nodes.py @@ -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 + +"""Tests for full-latent and tiled regional KSampler v3 nodes.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import torch + +from simple_syrup.nodes_v3.ksampler_prompt_by_region import ( + KSamplerPromptByRegionV3, +) +from simple_syrup.nodes_v3.ksampler_prompt_by_tiled_region import ( + KSamplerPromptByTiledRegionV3, +) + + +class FakeConditioningService: + """Record regional assembly and return recognizable conditioning.""" + + calls: ClassVar[list[dict[str, object]]] = [] + + def assemble(self, **kwargs: object) -> tuple[object, object]: + """Record one shared assembly request.""" + + type(self).calls.append(kwargs) + return "assembled-positive", "assembled-negative" + + +class FakeSamplingService: + """Record normal or tiled sampling arguments.""" + + calls: ClassVar[list[dict[str, Any]]] = [] + output: ClassVar[dict[str, Any]] = {"samples": torch.ones((1, 4, 2, 2))} + + def sample(self, **kwargs: Any) -> dict[str, Any]: + """Record and return one latent.""" + + type(self).calls.append(kwargs) + return self.output + + +def test_schemas_expose_exact_names_and_shared_regional_contract() -> None: + """Both nodes expose global-first regional inputs with only tiled extras.""" + + normal = KSamplerPromptByRegionV3.define_schema() + tiled = KSamplerPromptByTiledRegionV3.define_schema() + normal_ids = [value.id for value in normal.inputs] + tiled_ids = [value.id for value in tiled.inputs] + + assert normal.node_id == "SimpleSyrup.KSamplerPromptByRegion" + assert normal.display_name == "KSampler (Prompt by Region)" + assert tiled.node_id == "SimpleSyrup.KSamplerPromptByTiledRegion" + assert tiled.display_name == "KSampler (Prompt by Tiled Region)" + assert normal_ids == [ + "model", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "positive", + "negative", + "region_masks", + "regional_prompt_weight", + "region_mask_feather", + "latent_image", + "denoise", + ] + assert tiled_ids[: len(normal_ids)] == normal_ids + assert tiled_ids[len(normal_ids) :] == [ + "diffusion_mode", + "latent_tile_width", + "latent_tile_height", + "latent_tile_overlap", + "latent_tile_batch_size", + ] + regional_weight = normal.inputs[9] + assert regional_weight.default == 0.5 + assert regional_weight.min == 0.0 + assert regional_weight.max == 1.0 + assert [output.io_type for output in normal.outputs] == ["LATENT"] + assert [output.io_type for output in tiled.outputs] == ["LATENT"] + + +def test_non_tiled_node_assembles_then_samples_full_latent() -> None: + """The non-tiled node delegates regional and sampling concerns once.""" + + _reset_fakes() + original_conditioning = KSamplerPromptByRegionV3.conditioning_service_class + original_sampling = KSamplerPromptByRegionV3.sampling_service_class + KSamplerPromptByRegionV3.conditioning_service_class = FakeConditioningService # type: ignore[assignment] + KSamplerPromptByRegionV3.sampling_service_class = FakeSamplingService # type: ignore[assignment] + masks = torch.ones((2, 8, 8)) + latent = {"samples": torch.zeros((3, 4, 2, 2))} + try: + (output,) = KSamplerPromptByRegionV3.execute( + model="model", + seed=3, + steps=10, + cfg=5.0, + sampler_name="euler", + scheduler="normal", + positive="positive-batch", + negative="negative-batch", + region_masks=masks, + regional_prompt_weight=0.75, + region_mask_feather=4, + latent_image=latent, + denoise=0.7, + ) + finally: + KSamplerPromptByRegionV3.conditioning_service_class = original_conditioning + KSamplerPromptByRegionV3.sampling_service_class = original_sampling + + assert output is FakeSamplingService.output + assert FakeConditioningService.calls == [ + { + "positive": "positive-batch", + "negative": "negative-batch", + "masks": masks, + "regional_prompt_weight": 0.75, + "region_mask_feather": 4, + } + ] + call = FakeSamplingService.calls[0] + assert call["positive"] == "assembled-positive" + assert call["negative"] == "assembled-negative" + assert call["latent_image"] is latent + assert "diffusion_mode" not in call + + +def test_tiled_node_enables_only_full_context_regional_masks() -> None: + """The tiled node shares assembly and explicitly opts into mask support.""" + + _reset_fakes() + original_conditioning = KSamplerPromptByTiledRegionV3.conditioning_service_class + original_sampling = KSamplerPromptByTiledRegionV3.sampling_service_class + KSamplerPromptByTiledRegionV3.conditioning_service_class = FakeConditioningService # type: ignore[assignment] + KSamplerPromptByTiledRegionV3.sampling_service_class = FakeSamplingService # type: ignore[assignment] + masks = torch.ones((1, 8, 8)) + latent = {"samples": torch.zeros((1, 4, 16, 16))} + try: + (output,) = KSamplerPromptByTiledRegionV3.execute( + model="model", + seed=3, + steps=10, + cfg=5.0, + sampler_name="euler", + scheduler="normal", + positive="positive-batch", + negative="negative-batch", + region_masks=masks, + regional_prompt_weight=0.6, + region_mask_feather=0, + latent_image=latent, + denoise=0.7, + diffusion_mode="mixture_of_diffusers", + latent_tile_width=8, + latent_tile_height=8, + latent_tile_overlap=2, + latent_tile_batch_size=3, + ) + finally: + KSamplerPromptByTiledRegionV3.conditioning_service_class = original_conditioning + KSamplerPromptByTiledRegionV3.sampling_service_class = original_sampling + + assert output is FakeSamplingService.output + assert FakeConditioningService.calls == [ + { + "positive": "positive-batch", + "negative": "negative-batch", + "masks": masks, + "regional_prompt_weight": 0.6, + "region_mask_feather": 0, + } + ] + call = FakeSamplingService.calls[0] + assert call["diffusion_mode"] == "mixture_of_diffusers" + assert call["allow_full_context_masks"] is True + assert call["latent_tile_width"] == 8 + assert call["latent_tile_height"] == 8 + assert call["latent_tile_overlap"] == 2 + assert call["latent_tile_batch_size"] == 3 + + +def _reset_fakes() -> None: + """Clear shared fake records between node tests.""" + + FakeConditioningService.calls = [] + FakeSamplingService.calls = [] diff --git a/tests/test_regional_prompt_masks.py b/tests/test_regional_prompt_masks.py new file mode 100644 index 0000000..31b1b99 --- /dev/null +++ b/tests/test_regional_prompt_masks.py @@ -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 + +"""Tests for full-context regional prompt mask preparation.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.masking.regional_prompt_masks import ( + complementary_global_prompt_mask, +) + + +def test_complementary_global_mask_uses_clamped_accumulated_coverage() -> None: + """Overlapping regions accumulate while global coverage remains normalized.""" + + masks = torch.tensor( + [ + [[1.0, 1.0, 0.0]], + [[0.0, 1.0, 1.0]], + ] + ) + + global_mask = complementary_global_prompt_mask(masks, (0, 1), 0.5) + regional_total = masks.sum(dim=0, keepdim=True) * 0.5 + global_share = global_mask / (global_mask + regional_total) + + assert torch.equal(global_mask, torch.tensor([[[0.5, 0.5, 0.5]]])) + assert torch.allclose(global_share, torch.tensor([[[0.5, 1.0 / 3.0, 0.5]]])) + + +def test_complementary_global_mask_uses_only_paired_indices() -> None: + """Extra authored masks do not reduce global prompt influence.""" + + masks = torch.stack([torch.zeros((2, 2)), torch.ones((2, 2))]) + + global_mask = complementary_global_prompt_mask(masks, (0,), 1.0) + + assert torch.equal(global_mask, torch.ones((1, 2, 2))) + + +def test_complementary_global_mask_requires_a_regional_pair() -> None: + """Coverage cannot be calculated without a paired regional prompt.""" + + with pytest.raises(ValueError, match="at least one regional mask"): + complementary_global_prompt_mask(torch.ones((1, 2, 2)), (), 0.5) diff --git a/tests/test_regional_prompt_workflow.py b/tests/test_regional_prompt_workflow.py new file mode 100644 index 0000000..0875f3d --- /dev/null +++ b/tests/test_regional_prompt_workflow.py @@ -0,0 +1,142 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Integration test for the authored-mask regional prompt workflow.""" + +from __future__ import annotations + +from typing import Any, ClassVar, cast + +import pytest +import torch + +from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.nodes.encode_prompt_batch import EncodePromptBatch +from simple_syrup.nodes_v3 import load_mask_batch as load_node_module +from simple_syrup.nodes_v3.ksampler_prompt_by_region import ( + KSamplerPromptByRegionV3, +) +from simple_syrup.nodes_v3.load_mask_batch import LoadMaskBatchV3 + + +class WorkflowEncoder: + """Encode prompt chunks into standard recognizable conditioning.""" + + def encode_batch( + self, + clip: Any, + chunks: tuple[str, ...], + ) -> ConditioningBatch: + """Return ordered standard conditioning entries.""" + + del clip + return ConditioningBatch(tuple([[chunk, {}]] for chunk in chunks)) + + +class WorkflowMaskLoader: + """Return three authored masks in selected-file order.""" + + def load(self, files: list[str], channel: str) -> torch.Tensor: + """Return values that expose positional ordering.""" + + assert files == ["left.png", "right.png", "extra.png"] + assert channel == "red" + return torch.stack( + [ + torch.zeros((4, 4)), + torch.ones((4, 4)), + torch.full((4, 4), 0.5), + ] + ) + + def validate(self, files: str | list[str], channel: str) -> None: + """Accept integration-test widget values.""" + + del files, channel + + def fingerprint(self, files: list[str], channel: str) -> str: + """Provide the loader protocol for the node class.""" + + return f"{channel}:{files!r}" + + def available_files(self) -> tuple[str, ...]: + """Provide the schema protocol for the node class.""" + + return () + + +class WorkflowSampler: + """Capture assembled conditioning at the final sampling boundary.""" + + calls: ClassVar[list[dict[str, Any]]] = [] + + def sample(self, **kwargs: Any) -> dict[str, Any]: + """Return the input latent after recording the complete request.""" + + type(self).calls.append(kwargs) + return cast(dict[str, Any], kwargs["latent_image"]) + + +def test_sep_prompts_and_disk_masks_flow_into_regional_sampler( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The public nodes compose with global-first and missing-region behavior.""" + + monkeypatch.setattr(EncodePromptBatch, "encoder_class", WorkflowEncoder) + monkeypatch.setattr(LoadMaskBatchV3, "service_class", WorkflowMaskLoader) + monkeypatch.setattr( + load_node_module._comfy_ui, "PreviewMask", lambda mask, cls: None + ) + monkeypatch.setattr( + KSamplerPromptByRegionV3, + "sampling_service_class", + WorkflowSampler, + ) + WorkflowSampler.calls = [] + + positive, negative = EncodePromptBatch().encode( + clip=object(), + positive_prompt="global [SEP] left [SEP] right", + negative_prompt="global negative [SEP] left negative", + separator="[SEP]", + ) + mask_output = LoadMaskBatchV3.execute( + image=["left.png", "right.png", "extra.png"], + channel="red", + ) + assert mask_output.result is not None + masks = mask_output.result[0] + latent = {"samples": torch.zeros((2, 4, 1, 1))} + + (output,) = KSamplerPromptByRegionV3.execute( + model=object(), + seed=1, + steps=2, + cfg=3.0, + sampler_name="euler", + scheduler="normal", + positive=positive, + negative=negative, + region_masks=masks, + regional_prompt_weight=0.8, + region_mask_feather=0, + latent_image=latent, + denoise=1.0, + ) + + assert output is latent + call = WorkflowSampler.calls[0] + assembled_positive = call["positive"] + assembled_negative = call["negative"] + assert [item[0] for item in assembled_positive] == ["global", "left", "right"] + assert [item[0] for item in assembled_negative] == [ + "global negative", + "left negative", + ] + assert torch.equal(assembled_positive[1][1]["mask"], masks[0:1]) + assert torch.equal(assembled_positive[2][1]["mask"], masks[1:2]) + assert assembled_positive[1][1]["mask_strength"] == 0.8 + assert assembled_positive[2][1]["mask_strength"] == 0.8 + assert assembled_negative[1][1]["mask_strength"] == 0.8 + assert call["latent_image"]["samples"].shape[0] == 2 diff --git a/tests/test_regional_prompting_domain.py b/tests/test_regional_prompting_domain.py new file mode 100644 index 0000000..2727ba6 --- /dev/null +++ b/tests/test_regional_prompting_domain.py @@ -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 + +"""Tests for global-first regional prompt pairing policy.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.regional_prompting import ( + RegionalConditioningPair, + build_regional_conditioning_plan, + validate_regional_prompt_weight, +) + + +def test_pairs_every_available_regional_entry_in_order() -> None: + """Entry zero stays global and later entries map directly to masks.""" + + plan = build_regional_conditioning_plan( + region_count=3, + conditioning_count=3, + input_name="positive", + ) + + assert plan.region_count == 3 + assert plan.pairs == ( + RegionalConditioningPair(conditioning_index=1, mask_index=0), + RegionalConditioningPair(conditioning_index=2, mask_index=1), + ) + + +def test_fewer_regional_entries_than_masks_is_valid() -> None: + """Unpaired authored masks intentionally retain global conditioning only.""" + + plan = build_regional_conditioning_plan( + region_count=4, + conditioning_count=1, + input_name="negative", + ) + + assert plan.pairs == () + + +def test_more_regional_entries_than_masks_fails_actionably() -> None: + """Excess prompts cannot silently lose their positional meaning.""" + + with pytest.raises( + ValueError, + match="positive conditioning contains 2 regional entries but only 1", + ): + build_regional_conditioning_plan( + region_count=1, + conditioning_count=3, + input_name="positive", + ) + + +@pytest.mark.parametrize( + ("region_count", "conditioning_count", "message"), + [ + (0, 1, "at least one authored mask"), + (1, 0, "global entry at index 0"), + ], +) +def test_invalid_plan_counts_fail( + region_count: int, + conditioning_count: int, + message: str, +) -> None: + """A plan always contains masks and a structural global entry.""" + + with pytest.raises(ValueError, match=message): + build_regional_conditioning_plan( + region_count=region_count, + conditioning_count=conditioning_count, + input_name="positive", + ) + + +@pytest.mark.parametrize("weight", [0.0, 0.5, 1.0]) +def test_regional_prompt_weight_accepts_normalized_range(weight: float) -> None: + """Regional influence accepts both endpoints and the default midpoint.""" + + validate_regional_prompt_weight(weight) + + +@pytest.mark.parametrize("weight", [-0.01, 1.01, float("inf"), float("nan")]) +def test_regional_prompt_weight_rejects_invalid_values(weight: float) -> None: + """Invalid regional influence fails before conditioning assembly.""" + + with pytest.raises(ValueError, match="regional_prompt_weight"): + validate_regional_prompt_weight(weight) diff --git a/tests/test_registration.py b/tests/test_registration.py index e33d4a1..5c2f4c6 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -21,6 +21,7 @@ BASE_NODE_IDS = [ "SimpleSyrup.BatchSEGS", "SimpleSyrup.ConditioningBatchAppend", "SimpleSyrup.ConditioningBatchStart", + "SimpleSyrup.ComposeRegionalConditioning", "SimpleSyrup.DetailSEGSAsRegions", "SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion", "SimpleSyrup.DetailSEGSByScaleFactor", @@ -30,10 +31,13 @@ BASE_NODE_IDS = [ "SimpleSyrup.GroundedSAMModelInfo", "SimpleSyrup.GroundingDINOModelLoader", "SimpleSyrup.KSamplerExtras", + "SimpleSyrup.KSamplerPromptByRegion", + "SimpleSyrup.KSamplerPromptByTiledRegion", "SimpleSyrup.KSamplerTiledDiffusion", "SimpleSyrup.LatentDiagnostics", "SimpleSyrup.LayerStyleSAMModelsAdapter", "SimpleSyrup.LoadUltralyticsModel", + "SimpleSyrup.LoadMaskBatch", "SimpleSyrup.MaskToSEGS", "SimpleSyrup.PromptEncodeStyleAndNormalization", "SimpleSyrup.PromptEncodeStyle", @@ -120,7 +124,7 @@ def test_package_imports_from_custom_nodes_parent_path() -> None: def test_comfy_import_exposes_stable_internal_package_alias() -> None: - """ComfyUI-style import exposes `simple_syrup` for vendored runtime imports.""" + """ComfyUI-style import exposes `simple_syrup` to nested vendored packages.""" project_root = Path(__file__).resolve().parents[1] custom_nodes_root = project_root.parent @@ -132,9 +136,9 @@ def test_comfy_import_exposes_stable_internal_package_alias() -> None: f"sys.path.insert(0, {str(custom_nodes_root)!r}); " "importlib.import_module('SimpleSyrup'); " "runtime = importlib.import_module(" - "'simple_syrup.third_party.groundingdino_runtime.models'" + "'simple_syrup.third_party.groundingdino_runtime.util'" "); " - "assert runtime.__name__.endswith('groundingdino_runtime.models')" + "assert runtime.__name__.endswith('groundingdino_runtime.util')" ) result = subprocess.run( diff --git a/tests/test_tiled_diffusion_sampling_service.py b/tests/test_tiled_diffusion_sampling_service.py index 66f8886..fcc002b 100644 --- a/tests/test_tiled_diffusion_sampling_service.py +++ b/tests/test_tiled_diffusion_sampling_service.py @@ -269,4 +269,5 @@ def _sample_kwargs( "latent_tile_batch_size": 3, "preview_context": preview_context, "differential_diffusion": False, + "allow_full_context_masks": False, } diff --git a/tests/test_tiled_sampling_runtime.py b/tests/test_tiled_sampling_runtime.py index c319319..d78a398 100644 --- a/tests/test_tiled_sampling_runtime.py +++ b/tests/test_tiled_sampling_runtime.py @@ -191,3 +191,22 @@ def test_contains_unsupported_conditioning_key_finds_nested_values() -> None: conditioning = [{"model_conds": {"nested": [{"mask": torch.ones((1, 1))}]}}] assert tiled_sampling.contains_unsupported_conditioning_key(conditioning) + + +def test_full_context_masks_require_explicit_tiled_support() -> None: + """Only explicit non-cropped masks pass the regional tiled policy.""" + + supported = [ + ["tensor", {"mask": torch.ones((1, 2, 2)), "set_area_to_bounds": False}] + ] + cropped = [["tensor", {"mask": torch.ones((1, 2, 2)), "set_area_to_bounds": True}]] + + assert tiled_sampling.contains_unsupported_conditioning_key(supported) + assert not tiled_sampling.contains_unsupported_conditioning_key( + supported, + allow_full_context_masks=True, + ) + assert tiled_sampling.contains_unsupported_conditioning_key( + cropped, + allow_full_context_masks=True, + ) diff --git a/web/dist/simple-syrup.js b/web/dist/simple-syrup.js index 9579688..43db19a 100644 --- a/web/dist/simple-syrup.js +++ b/web/dist/simple-syrup.js @@ -6,6 +6,34 @@ var SETTINGS_ROUTE = "/simple-syrup/settings"; var EXTERNAL_LLM_SETTINGS_ROUTE = "/simple-syrup/external-llm/settings"; var EXTERNAL_LLM_API_KEY_ROUTE = "/simple-syrup/external-llm/api-key"; var EXTERNAL_LLM_MODELS_REFRESH_ROUTE = "/simple-syrup/external-llm/models/refresh"; +var MASK_BATCH_PREVIEW_ROUTE = "/simple-syrup/mask-batch/preview"; +async function getMaskBatchPreview(files, channel, fetchImpl = fetch) { + const response = await fetchImpl(MASK_BATCH_PREVIEW_ROUTE, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ files, channel }) + }); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not render Load Mask Batch preview. Backend returned ${String(response.status)}.` + ) + ); + } + return parseMaskBatchPreview(await response.json()); +} +function parseMaskBatchPreview(payload) { + if (!isMaskBatchPreviewPayload(payload)) { + throw new Error( + "SimpleSyrup mask batch preview payload is invalid. Expected native images and animation flags." + ); + } + return { + images: payload.images.map((image) => ({ ...image })), + animated: [...payload.animated] + }; +} async function getSettings(fetchImpl = fetch) { const response = await fetchImpl(SETTINGS_ROUTE); if (!response.ok) { @@ -123,6 +151,16 @@ function isExternalLLMSettingsPayload(payload) { (model) => typeof model === "string" ) === true && typeof payload.default_model === "string" && typeof payload.has_api_key === "boolean"; } +function isMaskBatchPreviewPayload(payload) { + if (typeof payload !== "object" || payload === null) return false; + const candidate = payload; + return Array.isArray(candidate.images) && candidate.images.every(isComfyImageResult) && Array.isArray(candidate.animated) && candidate.animated.every((value) => typeof value === "boolean"); +} +function isComfyImageResult(value) { + if (typeof value !== "object" || value === null) return false; + const candidate = value; + return typeof candidate.filename === "string" && typeof candidate.subfolder === "string" && (candidate.type === "input" || candidate.type === "output" || candidate.type === "temp"); +} async function backendErrorMessage(response, fallback) { try { const payload = await response.json(); @@ -510,8 +548,356 @@ function registerExternalLLMRefreshHook(app2, api = { refreshExternalLLMModels } }; } +// web/src/maskBatchPreview.ts +var EMPTY_PREVIEW = { + images: [], + animated: [] +}; +var PREVIEW_PROMPT_ID = "simple-syrup-mask-batch-preview"; +var MaskBatchPreviewController = class { + constructor(executionEvents, node, loadPreview = getMaskBatchPreview, logger = console, clearNativePreview2 = () => void 0) { + this.executionEvents = executionEvents; + this.node = node; + this.loadPreview = loadPreview; + this.logger = logger; + this.clearNativePreview = clearNativePreview2; + } + executionEvents; + node; + loadPreview; + logger; + clearNativePreview; + requestVersion = 0; + /** Replace the visible preview with the exact selected-channel mask output. */ + refresh(files, channel) { + if (this.node.id === void 0 || channel === void 0) return; + const requestVersion = ++this.requestVersion; + this.clearNativePreview(); + this.publish(EMPTY_PREVIEW); + if (files.length === 0) return; + void this.loadPreview([...files], channel).then((output) => { + if (requestVersion === this.requestVersion) this.publish(output); + }).catch((error) => { + if (requestVersion !== this.requestVersion) return; + this.logger.warn("Could not refresh Load Mask Batch preview.", error); + }); + } + /** Publish an execution-shaped result through Comfy's native preview. */ + publish(output) { + if (this.node.id === void 0) return; + this.executionEvents.dispatchEvent( + new CustomEvent("executed", { + detail: { + node: String(this.node.id), + output, + merge: false, + prompt_id: PREVIEW_PROMPT_ID + } + }) + ); + this.node.graph?.setDirtyCanvas?.(true, true); + } +}; + +// web/src/maskBatchUpload.ts +var LOAD_MASK_BATCH_NODE_ID = "SimpleSyrup.LoadMaskBatch"; +var REPLACE_MASKS_LABEL = "Replace masks..."; +var ADD_MASKS_LABEL = "Add masks..."; +var REMOVE_MASK_LABEL = "Remove selected mask"; +var EMPTY_MASK_SELECTION_LABEL = "No masks loaded"; +function registerMaskBatchUpload(app2, executionEvents, loadPreview, logger = console) { + const extension = { + name: "SimpleSyrup.LoadMaskBatchUpload", + nodeCreated(node) { + try { + configureMaskBatchNode(node, executionEvents, loadPreview, logger, app2); + } catch (error) { + logger.warn( + `Could not configure Load Mask Batch native controls: ${errorMessage2(error)}`, + error + ); + throw error; + } + } + }; + app2.registerExtension(extension); +} +function errorMessage2(error) { + return error instanceof Error && error.message ? error.message : String(error); +} +function configureMaskBatchNode(candidate, executionEvents, loadPreview, logger = console, app2) { + if (!isMaskBatchNode(candidate)) return; + const imageWidget = findWidget(candidate, "image"); + const channelWidget = findWidget(candidate, "channel"); + const uploadWidget = findNativeUploadWidget(candidate); + if (!imageWidget || !channelWidget || !uploadWidget?.callback) return; + hideInternalWidget(imageWidget); + hideInternalWidget(uploadWidget); + const preview = new MaskBatchPreviewController( + executionEvents, + candidate, + loadPreview, + logger, + () => { + clearNativePreview(candidate, app2); + } + ); + let selectionIntent = "replace"; + let appendBase = []; + let uploadPending = false; + let programmaticSelectionUpdate = false; + let ignoredUploadCallbackFiles; + const nativeUploadCallback = uploadWidget.callback; + const replaceWidget = candidate.addWidget( + "button", + "simple_syrup_replace_masks", + "image", + () => { + selectionIntent = "replace"; + appendBase = []; + uploadPending = true; + nativeUploadCallback.call(uploadWidget); + }, + nativeButtonOptions( + "Choose one or more masks and replace the current ordered list." + ) + ); + replaceWidget.label = REPLACE_MASKS_LABEL; + const setSelectedMasks = (files) => { + programmaticSelectionUpdate = true; + try { + imageWidget.value = [...files]; + } finally { + programmaticSelectionUpdate = false; + } + }; + uploadWidget.callback = (value) => { + selectionIntent = "replace"; + appendBase = []; + uploadPending = true; + nativeUploadCallback.call(uploadWidget, value); + }; + const addWidget = candidate.addWidget( + "button", + "simple_syrup_add_masks", + "image", + () => { + selectionIntent = "append"; + appendBase = selectedMaskFiles(imageWidget); + uploadPending = true; + nativeUploadCallback.call(uploadWidget); + }, + nativeButtonOptions( + "Upload one or more masks and append them after the current ordered list." + ) + ); + addWidget.label = ADD_MASKS_LABEL; + const selectedMaskWidget = candidate.addWidget( + "combo", + "simple_syrup_selected_mask", + EMPTY_MASK_SELECTION_LABEL, + (value) => { + const selectedIndex = selectedMaskIndex(selectedMaskWidget, value); + candidate.imageIndex = selectedIndex; + candidate.graph?.setDirtyCanvas?.(true, true); + }, + { + serialize: false, + tooltip: "Select one loaded mask by its ordered position for preview or removal.", + values: [] + } + ); + selectedMaskWidget.label = "selected mask"; + const removeWidget = candidate.addWidget( + "button", + "simple_syrup_remove_mask", + "image", + () => { + const previous = selectedMaskFiles(imageWidget); + const index = activeMaskIndex(candidate, selectedMaskWidget, previous); + if (index === null) return; + const current = previous.toSpliced(index, 1); + setSelectedMasks(current); + updateMaskSelection(selectedMaskWidget, current, index); + candidate.imageIndex = current.length === 0 ? null : Math.min(index, current.length - 1); + notifySelectionChanged(candidate, imageWidget, { current, previous }); + updateRemoveAvailability(removeWidget, current.length); + preview.refresh(current, selectedChannel(channelWidget)); + }, + nativeButtonOptions( + "Open a mask in the preview gallery, then remove that position from the loaded list." + ) + ); + removeWidget.label = REMOVE_MASK_LABEL; + imageWidget.callback = (value) => { + if (programmaticSelectionUpdate) return; + const callbackValue = value ?? imageWidget.value; + if (uploadPending && !Array.isArray(callbackValue)) return; + const incoming = normalizeMaskFiles(callbackValue); + if (ignoredUploadCallbackFiles && sameMaskFiles(incoming, ignoredUploadCallbackFiles)) { + ignoredUploadCallbackFiles = void 0; + return; + } + ignoredUploadCallbackFiles = uploadPending ? [...incoming] : void 0; + uploadPending = false; + const current = selectionIntent === "append" ? [...appendBase, ...incoming] : incoming; + const appended = selectionIntent === "append"; + selectionIntent = "replace"; + appendBase = []; + if (appended) setSelectedMasks(current); + candidate.imageIndex = null; + updateMaskSelection(selectedMaskWidget, current); + updateRemoveAvailability(removeWidget, current.length); + preview.refresh(current, selectedChannel(channelWidget)); + }; + const originalChannelCallback = channelWidget.callback; + channelWidget.callback = (value) => { + originalChannelCallback?.call(channelWidget, value); + const files = selectedMaskFiles(imageWidget); + if (files.length > 0) { + preview.refresh(files, selectedChannel(channelWidget, value)); + } + }; + resetIntentForExternalUploads(candidate, () => { + selectionIntent = "replace"; + appendBase = []; + uploadPending = false; + }); + const originalOnGraphConfigured = candidate.onGraphConfigured; + candidate.onGraphConfigured = function(...args) { + const result = originalOnGraphConfigured?.apply(this, args); + const restored = selectedMaskFiles(imageWidget); + if (!Array.isArray(imageWidget.value)) setSelectedMasks(restored); + updateMaskSelection(selectedMaskWidget, restored); + updateRemoveAvailability(removeWidget, restored.length); + if (restored.length > 0) { + preview.refresh(restored, selectedChannel(channelWidget)); + } + return result; + }; + const initial = selectedMaskFiles(imageWidget); + if (!Array.isArray(imageWidget.value)) setSelectedMasks(initial); + updateMaskSelection(selectedMaskWidget, initial); + updateRemoveAvailability(removeWidget, initial.length); + if (initial.length > 0) { + preview.refresh(initial, selectedChannel(channelWidget)); + } +} +function hideInternalWidget(widget) { + widget.options ??= {}; + widget.options.hidden = true; + widget.hidden = true; + widget.computeSize = () => [0, -4]; +} +function updateMaskSelection(widget, files, preferredIndex = 0) { + const values = files.map( + (file, index) => `${String(index + 1)}. ${file}` + ); + const hasMasks = values.length > 0; + widget.options ??= {}; + widget.options.values = hasMasks ? values : [EMPTY_MASK_SELECTION_LABEL]; + widget.disabled = !hasMasks; + widget.options.disabled = !hasMasks; + widget.value = hasMasks ? values[Math.min(Math.max(preferredIndex, 0), values.length - 1)] : EMPTY_MASK_SELECTION_LABEL; +} +function selectedMaskIndex(widget, callbackValue) { + if (widget.disabled) return null; + const value = callbackValue ?? widget.value; + const values = widget.options?.values ?? []; + const index = typeof value === "string" ? values.indexOf(value) : -1; + return index >= 0 ? index : null; +} +function clearNativePreview(node, app2) { + node.imgs = void 0; + node.images = []; + if (node.id !== void 0) delete app2?.nodeOutputs?.[String(node.id)]; + node.graph?.setDirtyCanvas?.(true, true); +} +function activeMaskIndex(node, selectedMaskWidget, files) { + const galleryIndex = node.imageIndex; + if (galleryIndex !== null && galleryIndex !== void 0 && galleryIndex >= 0 && galleryIndex < files.length) { + return galleryIndex; + } + const selectedIndex = selectedMaskIndex(selectedMaskWidget); + return selectedIndex !== null && selectedIndex < files.length ? selectedIndex : null; +} +function findWidget(node, name) { + return node.widgets?.find((widget) => widget.name === name); +} +function findNativeUploadWidget(node) { + return node.widgets?.find( + (widget) => widget.type === "button" && widget.value === "image" && widget.options?.serialize === false && widget.options.canvasOnly === true + ); +} +function nativeButtonOptions(tooltip) { + return { serialize: false, tooltip }; +} +function selectedChannel(channelWidget, callbackValue) { + const value = callbackValue ?? channelWidget.value; + return typeof value === "string" && value.length > 0 ? value : void 0; +} +function selectedMaskFiles(widget) { + return normalizeMaskFiles(widget.value); +} +function normalizeMaskFiles(value) { + const values = Array.isArray(value) ? value : [value]; + return values.filter( + (item) => typeof item === "string" && item.length > 0 + ); +} +function sameMaskFiles(left, right) { + return left.length === right.length && left.every((file, index) => file === right[index]); +} +function updateRemoveAvailability(widget, count) { + const disabled = count === 0; + widget.disabled = disabled; + widget.options ??= {}; + widget.options.disabled = disabled; +} +function notifySelectionChanged(node, imageWidget, change) { + node.onWidgetChanged?.( + imageWidget.name, + [...change.current], + [...change.previous], + imageWidget + ); + node.graph?.setDirtyCanvas?.(true, true); +} +function resetIntentForExternalUploads(node, reset) { + const originalPasteFiles = node.pasteFiles; + const originalOnDragDrop = node.onDragDrop; + const originalOnRemoved = node.onRemoved; + const wrappedPasteFiles = originalPasteFiles ? (...args) => { + reset(); + return originalPasteFiles.apply(node, args); + } : void 0; + const wrappedOnDragDrop = originalOnDragDrop ? (...args) => { + reset(); + return originalOnDragDrop.apply(node, args); + } : void 0; + if (wrappedPasteFiles) node.pasteFiles = wrappedPasteFiles; + if (wrappedOnDragDrop) node.onDragDrop = wrappedOnDragDrop; + node.onRemoved = function(...args) { + if (node.pasteFiles === wrappedPasteFiles) { + if (originalPasteFiles) node.pasteFiles = originalPasteFiles; + else delete node.pasteFiles; + } + if (node.onDragDrop === wrappedOnDragDrop) { + if (originalOnDragDrop) node.onDragDrop = originalOnDragDrop; + else delete node.onDragDrop; + } + return originalOnRemoved?.apply(this, args); + }; +} +function isMaskBatchNode(candidate) { + if (typeof candidate !== "object" || candidate === null) return false; + const node = candidate; + return node.constructor?.comfyClass === LOAD_MASK_BATCH_NODE_ID && typeof node.addWidget === "function"; +} + // web/src/main.ts var comfyApp = app; +var comfyExecutionEvents = window.comfyAPI.api.api; comfyApp.registerExtension({ name: "SimpleSyrup.Settings", async setup(appInstance) { @@ -519,3 +905,4 @@ comfyApp.registerExtension({ registerExternalLLMRefreshHook(appInstance); } }); +registerMaskBatchUpload(comfyApp, comfyExecutionEvents); diff --git a/web/src/api.ts b/web/src/api.ts index 7dc44c8..22c5094 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -2,6 +2,8 @@ // Copyright (C) 2026 Artificial Sweetener and contributors // SPDX-License-Identifier: AGPL-3.0-or-later +import type { ComfyImageResult, ComfyNodeExecutionOutput } from "./types"; + export interface SimpleSyrupSettings { show_downloadable_models: boolean; } @@ -32,6 +34,42 @@ const EXTERNAL_LLM_SETTINGS_ROUTE = "/simple-syrup/external-llm/settings"; const EXTERNAL_LLM_API_KEY_ROUTE = "/simple-syrup/external-llm/api-key"; const EXTERNAL_LLM_MODELS_REFRESH_ROUTE = "/simple-syrup/external-llm/models/refresh"; +const MASK_BATCH_PREVIEW_ROUTE = "/simple-syrup/mask-batch/preview"; + +export async function getMaskBatchPreview( + files: string[], + channel: string, + fetchImpl: FetchLike = fetch +): Promise { + const response = await fetchImpl(MASK_BATCH_PREVIEW_ROUTE, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ files, channel }) + }); + if (!response.ok) { + throw new Error( + await backendErrorMessage( + response, + `Could not render Load Mask Batch preview. Backend returned ${String(response.status)}.` + ) + ); + } + return parseMaskBatchPreview(await response.json()); +} + +export function parseMaskBatchPreview( + payload: unknown +): ComfyNodeExecutionOutput { + if (!isMaskBatchPreviewPayload(payload)) { + throw new Error( + "SimpleSyrup mask batch preview payload is invalid. Expected native images and animation flags." + ); + } + return { + images: payload.images.map((image) => ({ ...image })), + animated: [...payload.animated] + }; +} export async function getSettings( fetchImpl: FetchLike = fetch @@ -210,6 +248,31 @@ function isExternalLLMSettingsPayload( ); } +function isMaskBatchPreviewPayload( + payload: unknown +): payload is Required { + if (typeof payload !== "object" || payload === null) return false; + const candidate = payload as Partial; + return ( + Array.isArray(candidate.images) && + candidate.images.every(isComfyImageResult) && + Array.isArray(candidate.animated) && + candidate.animated.every((value) => typeof value === "boolean") + ); +} + +function isComfyImageResult(value: unknown): value is ComfyImageResult { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return ( + typeof candidate.filename === "string" && + typeof candidate.subfolder === "string" && + (candidate.type === "input" || + candidate.type === "output" || + candidate.type === "temp") + ); +} + async function backendErrorMessage( response: Response, fallback: string diff --git a/web/src/main.ts b/web/src/main.ts index 30c66d1..8f62569 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,9 +7,16 @@ import { app } from "../../../scripts/app.js"; import { registerSimpleSyrupSettings } from "./settings"; import { registerExternalLLMRefreshHook } from "./refresh"; -import type { ComfyApp } from "./types"; +import { registerMaskBatchUpload } from "./maskBatchUpload"; +import type { ComfyApp, ComfyExecutionEvents } from "./types"; + +interface ComfyRuntimeWindow extends Window { + comfyAPI: { api: { api: ComfyExecutionEvents } }; +} const comfyApp = app as unknown as ComfyApp; +const comfyExecutionEvents = (window as unknown as ComfyRuntimeWindow).comfyAPI + .api.api; comfyApp.registerExtension({ name: "SimpleSyrup.Settings", @@ -18,3 +25,5 @@ comfyApp.registerExtension({ registerExternalLLMRefreshHook(appInstance); } }); + +registerMaskBatchUpload(comfyApp, comfyExecutionEvents); diff --git a/web/src/maskBatchPreview.ts b/web/src/maskBatchPreview.ts new file mode 100644 index 0000000..1b0383c --- /dev/null +++ b/web/src/maskBatchPreview.ts @@ -0,0 +1,73 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { getMaskBatchPreview } from "./api"; +import type { + ComfyExecutionEvents, + ComfyNodeExecutionOutput, + Logger +} from "./types"; + +const EMPTY_PREVIEW: ComfyNodeExecutionOutput = { + images: [], + animated: [] +}; +const PREVIEW_PROMPT_ID = "simple-syrup-mask-batch-preview"; + +export type MaskBatchPreviewClient = ( + files: string[], + channel: string +) => Promise; + +export interface MaskBatchPreviewNode { + id?: string | number; + graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; +} + +/** Coordinate asynchronous native previews without publishing stale results. */ +export class MaskBatchPreviewController { + private requestVersion = 0; + + constructor( + private readonly executionEvents: ComfyExecutionEvents, + private readonly node: MaskBatchPreviewNode, + private readonly loadPreview: MaskBatchPreviewClient = getMaskBatchPreview, + private readonly logger: Logger = console, + private readonly clearNativePreview: () => void = () => undefined + ) {} + + /** Replace the visible preview with the exact selected-channel mask output. */ + refresh(files: string[], channel: string | undefined): void { + if (this.node.id === undefined || channel === undefined) return; + + const requestVersion = ++this.requestVersion; + this.clearNativePreview(); + this.publish(EMPTY_PREVIEW); + if (files.length === 0) return; + void this.loadPreview([...files], channel) + .then((output) => { + if (requestVersion === this.requestVersion) this.publish(output); + }) + .catch((error: unknown) => { + if (requestVersion !== this.requestVersion) return; + this.logger.warn("Could not refresh Load Mask Batch preview.", error); + }); + } + + /** Publish an execution-shaped result through Comfy's native preview. */ + private publish(output: ComfyNodeExecutionOutput): void { + if (this.node.id === undefined) return; + this.executionEvents.dispatchEvent( + new CustomEvent("executed", { + detail: { + node: String(this.node.id), + output, + merge: false, + prompt_id: PREVIEW_PROMPT_ID + } + }) + ); + this.node.graph?.setDirtyCanvas?.(true, true); + } +} diff --git a/web/src/maskBatchUpload.ts b/web/src/maskBatchUpload.ts new file mode 100644 index 0000000..9fe4ec0 --- /dev/null +++ b/web/src/maskBatchUpload.ts @@ -0,0 +1,486 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + MaskBatchPreviewController, + type MaskBatchPreviewClient +} from "./maskBatchPreview"; +import type { + ComfyApp, + ComfyExecutionEvents, + ComfyExtension, + ComfyImageResult, + Logger +} from "./types"; + +const LOAD_MASK_BATCH_NODE_ID = "SimpleSyrup.LoadMaskBatch"; +const REPLACE_MASKS_LABEL = "Replace masks..."; +const ADD_MASKS_LABEL = "Add masks..."; +const REMOVE_MASK_LABEL = "Remove selected mask"; +const EMPTY_MASK_SELECTION_LABEL = "No masks loaded"; + +type SelectionIntent = "append" | "replace"; +type NativeCallback = (...args: unknown[]) => unknown; + +interface MaskBatchWidget { + name: string; + value: unknown; + type?: string; + label?: string; + callback?: (value?: unknown) => void; + computeSize?: (width?: number) => [number, number]; + hidden?: boolean; + disabled?: boolean; + options?: { + canvasOnly?: boolean; + disabled?: boolean; + serialize?: boolean; + tooltip?: string; + hidden?: boolean; + values?: string[]; + }; +} + +interface MaskBatchNode { + constructor: { comfyClass?: string }; + id?: string | number; + widgets?: MaskBatchWidget[]; + imageIndex?: number | null; + images?: ComfyImageResult[]; + imgs?: unknown[] | undefined; + pasteFiles?: NativeCallback; + onDragDrop?: NativeCallback; + onRemoved?: NativeCallback; + onGraphConfigured?: NativeCallback; + onExecuted?: NativeCallback; + onWidgetChanged?: ( + name: string, + value: unknown, + previousValue: unknown, + widget: MaskBatchWidget + ) => void; + graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; + addWidget( + type: "button" | "combo", + name: string, + value: string | undefined, + callback: (value?: unknown) => void, + options: MaskBatchWidget["options"] + ): MaskBatchWidget; +} + +/** Register native upload-list controls for the mask batch loader. */ +export function registerMaskBatchUpload( + app: ComfyApp, + executionEvents: ComfyExecutionEvents, + loadPreview?: MaskBatchPreviewClient, + logger: Logger = console +): void { + const extension: ComfyExtension = { + name: "SimpleSyrup.LoadMaskBatchUpload", + nodeCreated(node: unknown) { + try { + configureMaskBatchNode(node, executionEvents, loadPreview, logger, app); + } catch (error: unknown) { + logger.warn( + `Could not configure Load Mask Batch native controls: ${errorMessage(error)}`, + error + ); + throw error; + } + } + }; + app.registerExtension(extension); +} + +/** Return a stable diagnostic message for an unknown frontend failure. */ +function errorMessage(error: unknown): string { + return error instanceof Error && error.message + ? error.message + : String(error); +} + +/** Wire native Comfy upload, append, remove, persistence, and preview behavior. */ +export function configureMaskBatchNode( + candidate: unknown, + executionEvents: ComfyExecutionEvents, + loadPreview?: MaskBatchPreviewClient, + logger: Logger = console, + app?: ComfyApp +): void { + if (!isMaskBatchNode(candidate)) return; + + const imageWidget = findWidget(candidate, "image"); + const channelWidget = findWidget(candidate, "channel"); + const uploadWidget = findNativeUploadWidget(candidate); + if (!imageWidget || !channelWidget || !uploadWidget?.callback) return; + + hideInternalWidget(imageWidget); + hideInternalWidget(uploadWidget); + + const preview = new MaskBatchPreviewController( + executionEvents, + candidate, + loadPreview, + logger, + () => { + clearNativePreview(candidate, app); + } + ); + let selectionIntent: SelectionIntent = "replace"; + let appendBase: string[] = []; + let uploadPending = false; + let programmaticSelectionUpdate = false; + let ignoredUploadCallbackFiles: string[] | undefined; + const nativeUploadCallback = uploadWidget.callback; + + const replaceWidget = candidate.addWidget( + "button", + "simple_syrup_replace_masks", + "image", + () => { + selectionIntent = "replace"; + appendBase = []; + uploadPending = true; + nativeUploadCallback.call(uploadWidget); + }, + nativeButtonOptions( + "Choose one or more masks and replace the current ordered list." + ) + ); + replaceWidget.label = REPLACE_MASKS_LABEL; + + const setSelectedMasks = (files: string[]): void => { + programmaticSelectionUpdate = true; + try { + imageWidget.value = [...files]; + } finally { + programmaticSelectionUpdate = false; + } + }; + + uploadWidget.callback = (value?: unknown) => { + selectionIntent = "replace"; + appendBase = []; + uploadPending = true; + nativeUploadCallback.call(uploadWidget, value); + }; + + const addWidget = candidate.addWidget( + "button", + "simple_syrup_add_masks", + "image", + () => { + selectionIntent = "append"; + appendBase = selectedMaskFiles(imageWidget); + uploadPending = true; + nativeUploadCallback.call(uploadWidget); + }, + nativeButtonOptions( + "Upload one or more masks and append them after the current ordered list." + ) + ); + addWidget.label = ADD_MASKS_LABEL; + + const selectedMaskWidget = candidate.addWidget( + "combo", + "simple_syrup_selected_mask", + EMPTY_MASK_SELECTION_LABEL, + (value?: unknown) => { + const selectedIndex = selectedMaskIndex(selectedMaskWidget, value); + candidate.imageIndex = selectedIndex; + candidate.graph?.setDirtyCanvas?.(true, true); + }, + { + serialize: false, + tooltip: + "Select one loaded mask by its ordered position for preview or removal.", + values: [] + } + ); + selectedMaskWidget.label = "selected mask"; + + const removeWidget = candidate.addWidget( + "button", + "simple_syrup_remove_mask", + "image", + () => { + const previous = selectedMaskFiles(imageWidget); + const index = activeMaskIndex(candidate, selectedMaskWidget, previous); + if (index === null) return; + const current = previous.toSpliced(index, 1); + + setSelectedMasks(current); + updateMaskSelection(selectedMaskWidget, current, index); + candidate.imageIndex = current.length === 0 ? null : Math.min(index, current.length - 1); + notifySelectionChanged(candidate, imageWidget, { current, previous }); + updateRemoveAvailability(removeWidget, current.length); + preview.refresh(current, selectedChannel(channelWidget)); + }, + nativeButtonOptions( + "Open a mask in the preview gallery, then remove that position from the loaded list." + ) + ); + removeWidget.label = REMOVE_MASK_LABEL; + + imageWidget.callback = (value?: unknown) => { + if (programmaticSelectionUpdate) return; + const callbackValue = value ?? imageWidget.value; + if (uploadPending && !Array.isArray(callbackValue)) return; + const incoming = normalizeMaskFiles(callbackValue); + if ( + ignoredUploadCallbackFiles && + sameMaskFiles(incoming, ignoredUploadCallbackFiles) + ) { + ignoredUploadCallbackFiles = undefined; + return; + } + ignoredUploadCallbackFiles = uploadPending ? [...incoming] : undefined; + uploadPending = false; + + const current = + selectionIntent === "append" ? [...appendBase, ...incoming] : incoming; + const appended = selectionIntent === "append"; + selectionIntent = "replace"; + appendBase = []; + + if (appended) setSelectedMasks(current); + candidate.imageIndex = null; + updateMaskSelection(selectedMaskWidget, current); + updateRemoveAvailability(removeWidget, current.length); + preview.refresh(current, selectedChannel(channelWidget)); + }; + + const originalChannelCallback = channelWidget.callback; + channelWidget.callback = (value?: unknown) => { + originalChannelCallback?.call(channelWidget, value); + const files = selectedMaskFiles(imageWidget); + if (files.length > 0) { + preview.refresh(files, selectedChannel(channelWidget, value)); + } + }; + + resetIntentForExternalUploads(candidate, () => { + selectionIntent = "replace"; + appendBase = []; + uploadPending = false; + }); + + const originalOnGraphConfigured = candidate.onGraphConfigured; + candidate.onGraphConfigured = function (...args: unknown[]): unknown { + const result = originalOnGraphConfigured?.apply(this, args); + const restored = selectedMaskFiles(imageWidget); + if (!Array.isArray(imageWidget.value)) setSelectedMasks(restored); + updateMaskSelection(selectedMaskWidget, restored); + updateRemoveAvailability(removeWidget, restored.length); + if (restored.length > 0) { + preview.refresh(restored, selectedChannel(channelWidget)); + } + return result; + }; + + const initial = selectedMaskFiles(imageWidget); + if (!Array.isArray(imageWidget.value)) setSelectedMasks(initial); + updateMaskSelection(selectedMaskWidget, initial); + updateRemoveAvailability(removeWidget, initial.length); + if (initial.length > 0) { + preview.refresh(initial, selectedChannel(channelWidget)); + } +} + +/** Keep serialized helper widgets out of both native node renderers. */ +function hideInternalWidget(widget: MaskBatchWidget): void { + widget.options ??= {}; + widget.options.hidden = true; + widget.hidden = true; + widget.computeSize = () => [0, -4]; +} + +/** Replace the native selector choices with ordered, duplicate-safe labels. */ +function updateMaskSelection( + widget: MaskBatchWidget, + files: string[], + preferredIndex = 0 +): void { + const values = files.map( + (file, index) => `${String(index + 1)}. ${file}` + ); + const hasMasks = values.length > 0; + widget.options ??= {}; + widget.options.values = hasMasks ? values : [EMPTY_MASK_SELECTION_LABEL]; + widget.disabled = !hasMasks; + widget.options.disabled = !hasMasks; + widget.value = hasMasks + ? values[Math.min(Math.max(preferredIndex, 0), values.length - 1)] + : EMPTY_MASK_SELECTION_LABEL; +} + +/** Resolve the selected ordered position from the native list widget. */ +function selectedMaskIndex( + widget: MaskBatchWidget, + callbackValue?: unknown +): number | null { + if (widget.disabled) return null; + const value = callbackValue ?? widget.value; + const values = widget.options?.values ?? []; + const index = typeof value === "string" ? values.indexOf(value) : -1; + return index >= 0 ? index : null; +} + +/** Clear classic and Nodes 2.0 preview state before publishing new output. */ +function clearNativePreview(node: MaskBatchNode, app?: ComfyApp): void { + node.imgs = undefined; + node.images = []; + if (node.id !== undefined) delete app?.nodeOutputs?.[String(node.id)]; + node.graph?.setDirtyCanvas?.(true, true); +} + +/** Prefer Comfy's active gallery image, then the native list selection. */ +function activeMaskIndex( + node: MaskBatchNode, + selectedMaskWidget: MaskBatchWidget, + files: string[] +): number | null { + const galleryIndex = node.imageIndex; + if ( + galleryIndex !== null && + galleryIndex !== undefined && + galleryIndex >= 0 && + galleryIndex < files.length + ) { + return galleryIndex; + } + const selectedIndex = selectedMaskIndex(selectedMaskWidget); + return selectedIndex !== null && selectedIndex < files.length + ? selectedIndex + : null; +} + +/** Return one native widget by its stable input name. */ +function findWidget( + node: MaskBatchNode, + name: string +): MaskBatchWidget | undefined { + return node.widgets?.find((widget) => widget.name === name); +} + +/** Find Comfy's image upload button without depending on localized labels. */ +function findNativeUploadWidget( + node: MaskBatchNode +): MaskBatchWidget | undefined { + return node.widgets?.find( + (widget) => + widget.type === "button" && + widget.value === "image" && + widget.options?.serialize === false && + widget.options.canvasOnly === true + ); +} + +/** Return options for a native button rendered by classic and Nodes 2.0. */ +function nativeButtonOptions(tooltip: string): MaskBatchWidget["options"] { + return { serialize: false, tooltip }; +} + +/** Return the newly selected native channel when it is usable. */ +function selectedChannel( + channelWidget: MaskBatchWidget, + callbackValue?: unknown +): string | undefined { + const value = callbackValue ?? channelWidget.value; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** Return the native multi-value widget as an ordered file list. */ +function selectedMaskFiles(widget: MaskBatchWidget): string[] { + return normalizeMaskFiles(widget.value); +} + +/** Narrow native scalar or multi-value widget state to valid file paths. */ +function normalizeMaskFiles(value: unknown): string[] { + const values = Array.isArray(value) ? value : [value]; + return values.filter( + (item): item is string => typeof item === "string" && item.length > 0 + ); +} + +/** Compare ordered upload results without relying on reactive proxy identity. */ +function sameMaskFiles(left: string[], right: string[]): boolean { + return ( + left.length === right.length && + left.every((file, index) => file === right[index]) + ); +} + +/** Disable removal only when the ordered mask list is empty. */ +function updateRemoveAvailability(widget: MaskBatchWidget, count: number): void { + const disabled = count === 0; + widget.disabled = disabled; + widget.options ??= {}; + widget.options.disabled = disabled; +} + +/** Notify Comfy when a non-upload control mutates persisted widget state. */ +function notifySelectionChanged( + node: MaskBatchNode, + imageWidget: MaskBatchWidget, + change: { current: string[]; previous: string[] } +): void { + node.onWidgetChanged?.( + imageWidget.name, + [...change.current], + [...change.previous], + imageWidget + ); + node.graph?.setDirtyCanvas?.(true, true); +} + +/** Prevent a cancelled Add action from affecting later paste or drop uploads. */ +function resetIntentForExternalUploads( + node: MaskBatchNode, + reset: () => void +): void { + const originalPasteFiles = node.pasteFiles; + const originalOnDragDrop = node.onDragDrop; + const originalOnRemoved = node.onRemoved; + + const wrappedPasteFiles: NativeCallback | undefined = originalPasteFiles + ? (...args: unknown[]): unknown => { + reset(); + return originalPasteFiles.apply(node, args); + } + : undefined; + const wrappedOnDragDrop: NativeCallback | undefined = originalOnDragDrop + ? (...args: unknown[]): unknown => { + reset(); + return originalOnDragDrop.apply(node, args); + } + : undefined; + + if (wrappedPasteFiles) node.pasteFiles = wrappedPasteFiles; + if (wrappedOnDragDrop) node.onDragDrop = wrappedOnDragDrop; + node.onRemoved = function (...args: unknown[]): unknown { + if (node.pasteFiles === wrappedPasteFiles) { + if (originalPasteFiles) node.pasteFiles = originalPasteFiles; + else delete node.pasteFiles; + } + if (node.onDragDrop === wrappedOnDragDrop) { + if (originalOnDragDrop) node.onDragDrop = originalOnDragDrop; + else delete node.onDragDrop; + } + return originalOnRemoved?.apply(this, args); + }; +} + +/** Return true when the candidate is the native-widget batch mask loader. */ +function isMaskBatchNode(candidate: unknown): candidate is MaskBatchNode { + if (typeof candidate !== "object" || candidate === null) return false; + const node = candidate as Partial; + return ( + node.constructor?.comfyClass === LOAD_MASK_BATCH_NODE_ID && + typeof node.addWidget === "function" + ); +} + +export type { MaskBatchPreviewClient } from "./maskBatchPreview"; diff --git a/web/src/types.ts b/web/src/types.ts index 4eb8286..628c659 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -27,6 +27,7 @@ export interface ComfySettingsApi { } export interface ComfyApp { + nodeOutputs?: Record; ui: { settings: ComfySettingsApi; }; @@ -34,7 +35,22 @@ export interface ComfyApp { registerExtension(extension: ComfyExtension): void; } +/** Native Comfy event target used to publish execution-shaped node output. */ +export type ComfyExecutionEvents = Pick; + +export interface ComfyImageResult { + filename: string; + subfolder: string; + type: "input" | "output" | "temp"; +} + +export interface ComfyNodeExecutionOutput { + images?: ComfyImageResult[]; + animated?: boolean[]; +} + export interface ComfyExtension { name: string; - setup(app: ComfyApp): void | Promise; + setup?(app: ComfyApp): void | Promise; + nodeCreated?(node: unknown): void | Promise; } diff --git a/web/tests/api.test.ts b/web/tests/api.test.ts index 12d6d4a..c5053d7 100644 --- a/web/tests/api.test.ts +++ b/web/tests/api.test.ts @@ -7,8 +7,10 @@ import { describe, expect, it, vi } from "vitest"; import { deleteExternalLLMApiKey, getExternalLLMSettings, + getMaskBatchPreview, getSettings, parseExternalLLMSettings, + parseMaskBatchPreview, parseSettings, refreshExternalLLMModels, saveExternalLLMApiKey, @@ -73,6 +75,62 @@ describe("settings API", () => { }); }); +describe("mask batch preview API", () => { + const preview = { + images: [ + { + filename: "ComfyUI_temp_mask.png", + subfolder: "", + type: "temp" as const + } + ], + animated: [false] + }; + + it("requests previews for the ordered files and selected channel", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(createJsonResponse(preview)); + + await expect( + getMaskBatchPreview(["right.png", "left.png"], "blue", fetchImpl) + ).resolves.toEqual(preview); + expect(fetchImpl).toHaveBeenCalledWith( + "/simple-syrup/mask-batch/preview", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + files: ["right.png", "left.png"], + channel: "blue" + }) + }) + ); + }); + + it("surfaces preview validation errors", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + createJsonResponse( + { error: "mask dimensions do not match" }, + { status: 400 } + ) + ); + + await expect( + getMaskBatchPreview(["right.png", "left.png"], "alpha", fetchImpl) + ).rejects.toThrow("mask dimensions do not match"); + }); + + it("rejects malformed native preview responses", () => { + expect(() => + parseMaskBatchPreview({ + images: [{ filename: "mask.png", subfolder: "", type: "other" }], + animated: [false] + }) + ).toThrow("mask batch preview payload is invalid"); + }); +}); + describe("external LLM settings API", () => { const payload = { base_url: "https://provider.example/v1", diff --git a/web/tests/maskBatchPreview.test.ts b/web/tests/maskBatchPreview.test.ts new file mode 100644 index 0000000..f03d879 --- /dev/null +++ b/web/tests/maskBatchPreview.test.ts @@ -0,0 +1,157 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from "vitest"; + +import { + MaskBatchPreviewController, + type MaskBatchPreviewClient +} from "../src/maskBatchPreview"; +import type { + ComfyNodeExecutionOutput, + Logger +} from "../src/types"; + +const OUTPUT: ComfyNodeExecutionOutput = { + images: [{ filename: "mask.png", subfolder: "", type: "temp" }], + animated: [false] +}; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve(value: T) { + if (!resolvePromise) throw new Error("Deferred promise was not initialized."); + resolvePromise(value); + } + }; +} + +describe("MaskBatchPreviewController", () => { + it("clears without requesting a preview for an empty native selection", () => { + const executionEvents = new EventTarget(); + const published: ComfyNodeExecutionOutput[] = []; + executionEvents.addEventListener("executed", (event) => { + published.push( + (event as CustomEvent<{ output: ComfyNodeExecutionOutput }>).detail + .output + ); + }); + const client = vi.fn(); + const clearNativePreview = vi.fn(); + const controller = new MaskBatchPreviewController( + executionEvents, + { id: 3 }, + client, + console, + clearNativePreview + ); + + controller.refresh([], "alpha"); + + expect(published).toEqual([{ images: [], animated: [] }]); + expect(clearNativePreview).toHaveBeenCalledOnce(); + expect(client).not.toHaveBeenCalled(); + }); + + it("publishes through Comfy's native execution pipeline and clears first", async () => { + const node = { id: 4 }; + const executionEvents = new EventTarget(); + const published: ComfyNodeExecutionOutput[] = []; + executionEvents.addEventListener("executed", (event) => { + published.push( + (event as CustomEvent<{ output: ComfyNodeExecutionOutput }>).detail + .output + ); + }); + const client = vi.fn().mockResolvedValue(OUTPUT); + const controller = new MaskBatchPreviewController( + executionEvents, + node, + client + ); + + controller.refresh(["one.png"], "red"); + + expect(published).toEqual([{ images: [], animated: [] }]); + await vi.waitFor(() => { + expect(published.at(-1)).toEqual(OUTPUT); + }); + expect(client).toHaveBeenCalledWith(["one.png"], "red"); + }); + + it("discards responses made stale by a newer channel request", async () => { + const first = deferred(); + const second = deferred(); + const client = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const executionEvents = new EventTarget(); + const published: ComfyNodeExecutionOutput[] = []; + executionEvents.addEventListener("executed", (event) => { + published.push( + (event as CustomEvent<{ output: ComfyNodeExecutionOutput }>).detail + .output + ); + }); + const controller = new MaskBatchPreviewController( + executionEvents, + { id: 5 }, + client + ); + const latest: ComfyNodeExecutionOutput = { + images: [{ filename: "blue.png", subfolder: "", type: "temp" }], + animated: [false] + }; + + controller.refresh(["mask.png"], "red"); + controller.refresh(["mask.png"], "blue"); + second.resolve(latest); + await vi.waitFor(() => { + expect(published.at(-1)).toEqual(latest); + }); + first.resolve(OUTPUT); + await Promise.resolve(); + + expect(published.at(-1)).toEqual(latest); + }); + + it("keeps the cleared preview and reports current request failures", async () => { + const error = new Error("preview failed"); + const client = vi.fn().mockRejectedValue(error); + const logger: Logger = { warn: vi.fn() }; + const executionEvents = new EventTarget(); + const published: ComfyNodeExecutionOutput[] = []; + executionEvents.addEventListener("executed", (event) => { + published.push( + (event as CustomEvent<{ output: ComfyNodeExecutionOutput }>).detail + .output + ); + }); + const controller = new MaskBatchPreviewController( + executionEvents, + { id: 6 }, + client, + logger + ); + + controller.refresh(["mask.png"], "alpha"); + + await vi.waitFor(() => { + expect(logger.warn).toHaveBeenCalledWith( + "Could not refresh Load Mask Batch preview.", + error + ); + }); + expect(published).toEqual([{ images: [], animated: [] }]); + }); +}); diff --git a/web/tests/maskBatchUpload.test.ts b/web/tests/maskBatchUpload.test.ts new file mode 100644 index 0000000..b49c121 --- /dev/null +++ b/web/tests/maskBatchUpload.test.ts @@ -0,0 +1,500 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from "vitest"; + +import { + configureMaskBatchNode, + registerMaskBatchUpload +} from "../src/maskBatchUpload"; +import type { MaskBatchPreviewClient } from "../src/maskBatchUpload"; +import type { + ComfyApp, + ComfyExtension, + ComfyNodeExecutionOutput +} from "../src/types"; + +interface TestWidget { + name: string; + value: unknown; + type?: string; + label?: string; + callback?: (value?: unknown) => unknown; + computeSize?: (width?: number) => [number, number]; + hidden?: boolean; + serializeValue?: () => unknown; + disabled?: boolean; + options?: { + canvasOnly?: boolean; + disabled?: boolean; + serialize?: boolean; + tooltip?: string; + hidden?: boolean; + values?: string[]; + }; +} + +const PREVIEW_OUTPUT: ComfyNodeExecutionOutput = { + images: [ + { + filename: "ComfyUI_temp_mask.png", + subfolder: "", + type: "temp" + } + ], + animated: [false] +}; + +function createNode(value: unknown = []) { + const nativeUploadCallback = vi.fn(); + const channelCallback = vi.fn(); + const onWidgetChanged = vi.fn(); + const setDirtyCanvas = vi.fn(); + const pasteFiles = vi.fn(() => true); + const onDragDrop = vi.fn(() => Promise.resolve(true)); + const originalOnRemoved = vi.fn(); + const imageWidget: TestWidget = { + name: "image", + value, + type: "combo", + options: { canvasOnly: true } + }; + const channelWidget: TestWidget = { + name: "channel", + value: "alpha", + type: "combo", + callback: channelCallback + }; + const uploadWidget: TestWidget = { + name: "upload", + value: "image", + type: "button", + label: "choose file to upload", + callback: nativeUploadCallback, + options: { serialize: false, canvasOnly: true } + }; + const widgets = [imageWidget, channelWidget, uploadWidget]; + const node = { + constructor: { comfyClass: "SimpleSyrup.LoadMaskBatch" }, + id: 7, + widgets, + properties: {} as Record, + imageIndex: null as number | null, + images: undefined as ComfyNodeExecutionOutput["images"], + imgs: undefined as unknown[] | undefined, + pasteFiles, + onDragDrop, + onRemoved: originalOnRemoved as (...args: unknown[]) => unknown, + onGraphConfigured: undefined as ((...args: unknown[]) => unknown) | undefined, + onWidgetChanged, + graph: { setDirtyCanvas }, + addWidget( + type: "button", + name: string, + widgetValue: string | undefined, + callback: (value?: unknown) => void, + options: TestWidget["options"] + ): TestWidget { + const widget: TestWidget = { + type, + name, + value: widgetValue, + callback + }; + if (options) widget.options = options; + widgets.push(widget); + return widget; + } + }; + return { + channelCallback, + channelWidget, + imageWidget, + nativeUploadCallback, + node, + onDragDrop, + onWidgetChanged, + originalOnRemoved, + pasteFiles, + setDirtyCanvas, + uploadWidget + }; +} + +function createExecutionEvents(): EventTarget { + return new EventTarget(); +} + +function previewClient( + output: ComfyNodeExecutionOutput = PREVIEW_OUTPUT +): ReturnType> { + return vi.fn().mockResolvedValue(output); +} + +function button(node: ReturnType["node"], name: string) { + const widget = node.widgets.find((candidate) => candidate.name === name); + if (!widget) throw new Error(`Missing test button ${name}.`); + return widget; +} + +function selectFiles(imageWidget: TestWidget, files: string[]): void { + imageWidget.value = files; + imageWidget.callback?.(files); +} + +describe("Load Mask Batch native upload integration", () => { + it("registers a node-created hook", () => { + let extension: ComfyExtension | undefined; + const app = { + registerExtension(value: ComfyExtension) { + extension = value; + } + } as ComfyApp; + + registerMaskBatchUpload(app, createExecutionEvents()); + + expect(extension?.name).toBe("SimpleSyrup.LoadMaskBatchUpload"); + expect(typeof extension?.nodeCreated).toBe("function"); + }); + + it("reports native node-configuration failures with context", () => { + let extension: ComfyExtension | undefined; + const logger = { warn: vi.fn() }; + const app = { + registerExtension(value: ComfyExtension) { + extension = value; + } + } as ComfyApp; + registerMaskBatchUpload(app, createExecutionEvents(), undefined, logger); + const { node } = createNode(); + node.addWidget = () => { + throw new Error("native widget failed"); + }; + + expect(() => extension?.nodeCreated?.(node)).toThrow( + "native widget failed" + ); + expect(logger.warn).toHaveBeenCalledWith( + "Could not configure Load Mask Batch native controls: native widget failed", + expect.any(Error) + ); + }); + + it("uses native cross-renderer controls and hides internal upload state", () => { + const { imageWidget, node, uploadWidget } = createNode(["only.png"]); + configureMaskBatchNode(node, createExecutionEvents()); + + const replace = button(node, "simple_syrup_replace_masks"); + const add = button(node, "simple_syrup_add_masks"); + const selected = button(node, "simple_syrup_selected_mask"); + const remove = button(node, "simple_syrup_remove_mask"); + expect(uploadWidget.label).toBe("choose file to upload"); + expect(imageWidget.options).toMatchObject({ + canvasOnly: true, + hidden: true + }); + expect(uploadWidget.options).toMatchObject({ + canvasOnly: true, + hidden: true + }); + expect(imageWidget.computeSize?.()).toEqual([0, -4]); + expect(imageWidget.hidden).toBe(true); + expect(uploadWidget.computeSize?.()).toEqual([0, -4]); + expect(replace.label).toBe("Replace masks..."); + expect(add.label).toBe("Add masks..."); + expect(selected.label).toBe("selected mask"); + expect(selected.value).toBe("1. only.png"); + expect(selected.options?.values).toEqual(["1. only.png"]); + expect(remove.label).toBe("Remove selected mask"); + expect(add.type).toBe("button"); + expect(add.options).toMatchObject({ serialize: false }); + expect(add.options?.canvasOnly).toBeUndefined(); + expect(remove.disabled).toBe(false); + expect(remove.options?.disabled).toBe(false); + }); + + it("shows an explicit disabled empty state instead of undefined", () => { + const { node } = createNode([]); + + configureMaskBatchNode(node, createExecutionEvents()); + + const selected = button(node, "simple_syrup_selected_mask"); + expect(selected.value).toBe("No masks loaded"); + expect(selected.options?.values).toEqual(["No masks loaded"]); + expect(selected.disabled).toBe(true); + expect(selected.options?.disabled).toBe(true); + expect(button(node, "simple_syrup_remove_mask").disabled).toBe(true); + }); + + it("uses the native multi-value widget as the only selection owner", async () => { + const { imageWidget, node } = createNode([]); + const loadPreview = previewClient(); + configureMaskBatchNode(node, createExecutionEvents(), loadPreview); + loadPreview.mockClear(); + + button(node, "simple_syrup_add_masks").callback?.(); + const uploaded = ["one.png", "two.png", "three.png"]; + selectFiles(imageWidget, uploaded); + + expect(imageWidget.value).toEqual(uploaded); + expect(imageWidget.serializeValue).toBeUndefined(); + expect(node.properties).toEqual({}); + await vi.waitFor(() => { + expect(loadPreview).toHaveBeenCalledWith(uploaded, "alpha"); + }); + }); + + it("accepts direct native list edits and clears without a backend request", async () => { + const { imageWidget, node } = createNode(["one.png", "two.png"]); + const loadPreview = previewClient(); + configureMaskBatchNode(node, createExecutionEvents(), loadPreview); + loadPreview.mockClear(); + + selectFiles(imageWidget, ["two.png"]); + await vi.waitFor(() => { + expect(loadPreview).toHaveBeenCalledWith(["two.png"], "alpha"); + }); + + loadPreview.mockClear(); + selectFiles(imageWidget, []); + + expect(imageWidget.value).toEqual([]); + expect(loadPreview).not.toHaveBeenCalled(); + expect(button(node, "simple_syrup_remove_mask").disabled).toBe(true); + }); + + it("normalizes a serialized scalar from an older workflow", async () => { + const { imageWidget, node } = createNode("saved-mask.png"); + const loadPreview = previewClient(); + configureMaskBatchNode(node, createExecutionEvents(), loadPreview); + loadPreview.mockClear(); + + node.onGraphConfigured?.(); + + expect(imageWidget.value).toEqual(["saved-mask.png"]); + await vi.waitFor(() => { + expect(loadPreview).toHaveBeenCalledWith(["saved-mask.png"], "alpha"); + }); + }); + + it("replaces the ordered list through the explicit native control", async () => { + const { imageWidget, nativeUploadCallback, node } = createNode([ + "old.png" + ]); + const loadPreview = previewClient(); + configureMaskBatchNode(node, createExecutionEvents(), loadPreview); + loadPreview.mockClear(); + + button(node, "simple_syrup_replace_masks").callback?.(); + const replacement = ["right.png", "left.png"]; + selectFiles(imageWidget, replacement); + + expect(nativeUploadCallback).toHaveBeenCalledOnce(); + expect(imageWidget.value).toEqual(replacement); + expect(imageWidget.serializeValue).toBeUndefined(); + expect(node.properties).toEqual({}); + await vi.waitFor(() => { + expect(loadPreview).toHaveBeenCalledWith(replacement, "alpha"); + }); + }); + + it("appends native upload results in order and preserves duplicate paths", async () => { + const { imageWidget, nativeUploadCallback, node } = createNode(); + const loadPreview = previewClient(); + configureMaskBatchNode(node, createExecutionEvents(), loadPreview); + selectFiles(imageWidget, ["one.png", "duplicate.png"]); + loadPreview.mockClear(); + + button(node, "simple_syrup_add_masks").callback?.(); + imageWidget.value = "two.png"; + imageWidget.callback?.("two.png"); + const uploaded = ["two.png", "duplicate.png"]; + selectFiles(imageWidget, uploaded); + + const expected = [ + "one.png", + "duplicate.png", + "two.png", + "duplicate.png" + ]; + expect(nativeUploadCallback).toHaveBeenCalledOnce(); + expect(uploaded).toEqual(["two.png", "duplicate.png"]); + expect(imageWidget.value).toEqual(expected); + expect(imageWidget.serializeValue).toBeUndefined(); + expect(node.properties).toEqual({}); + await vi.waitFor(() => { + expect(loadPreview).toHaveBeenCalledWith(expected, "alpha"); + }); + }); + + it("avoids recursive updates from the Nodes 2.0 reactive multiselect", async () => { + const { imageWidget, node } = createNode(["one.png"]); + let storedValue = imageWidget.value; + Object.defineProperty(imageWidget, "value", { + configurable: true, + get: () => storedValue, + set: (value: unknown) => { + storedValue = value; + imageWidget.callback?.(value); + } + }); + const loadPreview = previewClient(); + configureMaskBatchNode(node, createExecutionEvents(), loadPreview); + loadPreview.mockClear(); + + button(node, "simple_syrup_add_masks").callback?.(); + const uploaded = ["two.png", "three.png"]; + imageWidget.value = uploaded; + imageWidget.callback?.([...uploaded]); + + expect(imageWidget.value).toEqual([ + "one.png", + "two.png", + "three.png" + ]); + await vi.waitFor(() => { + expect(loadPreview).toHaveBeenCalledTimes(1); + }); + }); + + it("cancelled Add cannot affect later replace, paste, or drop selections", () => { + const { imageWidget, node, onDragDrop, pasteFiles, uploadWidget } = + createNode(); + configureMaskBatchNode(node, createExecutionEvents()); + selectFiles(imageWidget, ["existing.png"]); + + button(node, "simple_syrup_add_masks").callback?.(); + uploadWidget.callback?.(); + selectFiles(imageWidget, ["replace.png"]); + expect(imageWidget.value).toEqual(["replace.png"]); + + button(node, "simple_syrup_add_masks").callback?.(); + node.pasteFiles(); + selectFiles(imageWidget, ["paste.png"]); + expect(imageWidget.value).toEqual(["paste.png"]); + expect(pasteFiles).toHaveBeenCalledOnce(); + + button(node, "simple_syrup_add_masks").callback?.(); + void node.onDragDrop(); + selectFiles(imageWidget, ["drop.png"]); + expect(imageWidget.value).toEqual(["drop.png"]); + expect(onDragDrop).toHaveBeenCalledOnce(); + }); + + it("removes the active gallery position and never deletes by filename", async () => { + const { imageWidget, node, onWidgetChanged } = createNode(); + const loadPreview = previewClient(); + configureMaskBatchNode(node, createExecutionEvents(), loadPreview); + selectFiles(imageWidget, ["duplicate.png", "middle.png", "duplicate.png"]); + loadPreview.mockClear(); + onWidgetChanged.mockClear(); + node.imageIndex = 2; + + button(node, "simple_syrup_remove_mask").callback?.(); + + const remaining = ["duplicate.png", "middle.png"]; + expect(imageWidget.value).toEqual(remaining); + expect(node.imageIndex).toBe(1); + expect(onWidgetChanged).toHaveBeenCalledWith( + "image", + remaining, + ["duplicate.png", "middle.png", "duplicate.png"], + imageWidget + ); + await vi.waitFor(() => { + expect(loadPreview).toHaveBeenCalledWith(remaining, "alpha"); + }); + }); + + it("removes the native list selection and permits clearing the final mask", () => { + const { imageWidget, node, onWidgetChanged } = createNode(); + configureMaskBatchNode(node, createExecutionEvents()); + selectFiles(imageWidget, ["one.png", "two.png"]); + const selected = button(node, "simple_syrup_selected_mask"); + const remove = button(node, "simple_syrup_remove_mask"); + + selected.callback?.("2. two.png"); + remove.callback?.(); + expect(imageWidget.value).toEqual(["one.png"]); + expect(selected.value).toBe("1. one.png"); + expect(remove.disabled).toBe(false); + + remove.callback?.(); + expect(imageWidget.value).toEqual([]); + expect(selected.value).toBe("No masks loaded"); + expect(selected.options?.values).toEqual(["No masks loaded"]); + expect(selected.disabled).toBe(true); + expect(remove.disabled).toBe(true); + expect(onWidgetChanged).toHaveBeenCalledTimes(2); + }); + + it("clears stale native preview state when the final mask is removed", () => { + const { imageWidget, node } = createNode(["one.png"]); + const loadPreview = previewClient(); + const app = { + nodeOutputs: { "7": PREVIEW_OUTPUT }, + registerExtension: vi.fn(), + ui: { settings: { addSetting: vi.fn() } } + } as unknown as ComfyApp; + configureMaskBatchNode( + node, + createExecutionEvents(), + loadPreview, + console, + app + ); + node.imgs = [{}]; + node.images = PREVIEW_OUTPUT.images; + app.nodeOutputs = { "7": PREVIEW_OUTPUT }; + + selectFiles(imageWidget, []); + + expect(node.imgs).toBeUndefined(); + expect(node.images).toEqual([]); + expect(node.imageIndex).toBeNull(); + expect(app.nodeOutputs).toEqual({}); + }); + + it("restores a persisted batch and refreshes the selected channel", async () => { + const { channelWidget, imageWidget, node } = createNode([ + "one.png", + "two.png" + ]); + const loadPreview = previewClient(); + configureMaskBatchNode(node, createExecutionEvents(), loadPreview); + loadPreview.mockClear(); + channelWidget.value = "blue"; + + node.onGraphConfigured?.(); + + expect(imageWidget.value).toEqual(["one.png", "two.png"]); + await vi.waitFor(() => { + expect(loadPreview).toHaveBeenCalledWith( + ["one.png", "two.png"], + "blue" + ); + }); + }); + + it("restores native paste and drop handlers before native node cleanup", () => { + const { node, onDragDrop, originalOnRemoved, pasteFiles } = createNode(); + configureMaskBatchNode(node, createExecutionEvents()); + + node.onRemoved(); + + expect(originalOnRemoved).toHaveBeenCalledOnce(); + expect(node.pasteFiles).toBe(pasteFiles); + expect(node.onDragDrop).toBe(onDragDrop); + }); + + it("does not modify unrelated nodes", () => { + const { node, uploadWidget } = createNode(); + node.constructor.comfyClass = "LoadImageMask"; + + configureMaskBatchNode(node, createExecutionEvents()); + + expect(uploadWidget.label).toBe("choose file to upload"); + expect(node.widgets).toHaveLength(3); + }); +}); From 546ee5db01872819232dda9fa419eb3f4643210f Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Mon, 27 Jul 2026 14:06:18 -0400 Subject: [PATCH 24/40] fix(regional): align prompt batches and LoRA hooks --- .../domain/prompt_segment_alignment.py | 94 +++++++++++ simple_syrup/masking/regional_prompt_masks.py | 18 --- simple_syrup/nodes/encode_prompt_batch.py | 27 ++-- ..._and_encode_prompts_with_prompt_control.py | 6 +- simple_syrup/nodes_v3/__init__.py | 6 + .../attach_regional_global_conditioning.py | 71 +++++++++ ...encode_prompt_batch_with_prompt_control.py | 10 +- .../nodes_v3/prepare_regional_lora_hooks.py | 72 +++++++++ ..._and_encode_prompts_with_prompt_control.py | 9 +- .../runtime/prompt_control_batch_graph.py | 31 +++- .../runtime/prompt_control_graph_adapter.py | 79 ++++++++-- .../prompt_control_schedule_encode_graph.py | 39 +++-- .../regional_conditioning_companion.py | 64 ++++++++ simple_syrup/runtime/regional_lora_hooks.py | 60 +++++++ .../services/prompt_batch_encoding_service.py | 59 +++++++ ...prompt_control_segment_planning_service.py | 52 +++++- .../services/regional_conditioning_service.py | 49 +++--- tests/test_encode_prompt_batch_node.py | 31 +++- ...e_prompt_batch_with_prompt_control_node.py | 3 + tests/test_prompt_control_batch_graph.py | 116 +++++++++++++- ...st_prompt_control_schedule_encode_graph.py | 110 +++++++++++-- ...prompt_control_segment_planning_service.py | 58 ++++++- tests/test_prompt_segment_alignment.py | 76 +++++++++ tests/test_regional_conditioning_companion.py | 51 ++++++ tests/test_regional_conditioning_service.py | 85 ++++++++-- tests/test_regional_lora_hooks.py | 149 ++++++++++++++++++ tests/test_regional_prompt_masks.py | 40 +++-- tests/test_regional_prompt_workflow.py | 5 + tests/test_registration.py | 2 + ...encode_prompts_with_prompt_control_node.py | 1 + 30 files changed, 1323 insertions(+), 150 deletions(-) create mode 100644 simple_syrup/domain/prompt_segment_alignment.py create mode 100644 simple_syrup/nodes_v3/attach_regional_global_conditioning.py create mode 100644 simple_syrup/nodes_v3/prepare_regional_lora_hooks.py create mode 100644 simple_syrup/runtime/regional_conditioning_companion.py create mode 100644 simple_syrup/runtime/regional_lora_hooks.py create mode 100644 simple_syrup/services/prompt_batch_encoding_service.py create mode 100644 tests/test_prompt_segment_alignment.py create mode 100644 tests/test_regional_conditioning_companion.py create mode 100644 tests/test_regional_lora_hooks.py diff --git a/simple_syrup/domain/prompt_segment_alignment.py b/simple_syrup/domain/prompt_segment_alignment.py new file mode 100644 index 0000000..ba3f105 --- /dev/null +++ b/simple_syrup/domain/prompt_segment_alignment.py @@ -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) + ), + ) diff --git a/simple_syrup/masking/regional_prompt_masks.py b/simple_syrup/masking/regional_prompt_masks.py index aee70eb..5215d70 100644 --- a/simple_syrup/masking/regional_prompt_masks.py +++ b/simple_syrup/masking/regional_prompt_masks.py @@ -6,8 +6,6 @@ from __future__ import annotations -from collections.abc import Sequence - import torch from .detailer_masks import gaussian_feather_mask @@ -43,19 +41,3 @@ def regional_mask(mask_batch: torch.Tensor, index: int) -> torch.Tensor: 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] - - -def complementary_global_prompt_mask( - mask_batch: torch.Tensor, - mask_indices: Sequence[int], - regional_prompt_weight: float, -) -> torch.Tensor: - """Return global influence that recedes across accumulated region coverage.""" - - if not mask_indices: - raise ValueError("global prompt masking requires at least one regional mask.") - coverage = torch.zeros_like(mask_batch[0:1]) - for index in mask_indices: - coverage.add_(regional_mask(mask_batch, index)) - coverage.clamp_(0.0, 1.0) - return 1.0 - coverage * regional_prompt_weight diff --git a/simple_syrup/nodes/encode_prompt_batch.py b/simple_syrup/nodes/encode_prompt_batch.py index 4702e3c..08654e7 100644 --- a/simple_syrup/nodes/encode_prompt_batch.py +++ b/simple_syrup/nodes/encode_prompt_batch.py @@ -8,8 +8,8 @@ from __future__ import annotations from typing import Any, ClassVar -from ..domain.conditioning_batch import split_prompt_batch from ..runtime.conditioning_encoding import ComfyConditioningEncoder +from ..services.prompt_batch_encoding_service import PromptBatchEncodingService class EncodePromptBatch: @@ -23,10 +23,16 @@ class EncodePromptBatch: ) FUNCTION = "encode" CATEGORY = "SimpleSyrup/Conditioning" - DESCRIPTION = "Encodes [SEP]-separated prompts into ordered conditioning batches." + DESCRIPTION = ( + "Encodes [SEP]-separated prompts 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, ...]]]: @@ -49,7 +55,8 @@ class EncodePromptBatch: "default": "", "multiline": True, "tooltip": ( - "Ordered positive prompt entries separated by [SEP]." + "Ordered positive prompt entries separated by [SEP]; " + "the global entry fills missing positive regions." ), }, ), @@ -59,7 +66,8 @@ class EncodePromptBatch: "default": "", "multiline": True, "tooltip": ( - "Ordered negative prompt entries separated by [SEP]." + "Ordered negative prompt entries separated by [SEP]; " + "the global entry fills missing negative regions." ), }, ), @@ -82,10 +90,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, ) diff --git a/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py b/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py index c9c18f4..1d9266e 100644 --- a/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py +++ b/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py @@ -31,7 +31,7 @@ class ScheduleAndEncodePromptsWithPromptControl: CATEGORY = "SimpleSyrup/Conditioning" DESCRIPTION = ( "Schedules Prompt-Control LoRAs and encodes prompts. [SEP] creates " - "conditioning batches with segment-local LoRA hooks." + "matched conditioning batches using global text for missing regions." ) SEARCH_ALIASES = ["prompt control", "schedule prompts", "encode prompts"] @@ -68,7 +68,7 @@ class ScheduleAndEncodePromptsWithPromptControl: "multiline": False, "tooltip": ( "Positive Prompt-Control text; [SEP] creates ordered " - "entries with segment-local LoRA hooks." + "entries, and global text fills missing positive regions." ), }, ), @@ -79,7 +79,7 @@ class ScheduleAndEncodePromptsWithPromptControl: "multiline": False, "tooltip": ( "Negative Prompt-Control text; [SEP] creates ordered " - "entries sharing each index's LoRA hooks." + "entries, and global text fills missing negative regions." ), }, ), diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index aa2de4a..ad3da19 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -101,16 +101,22 @@ def get_nodes() -> list[type[object]]: if not prompt_control_is_available(): return nodes + from .attach_regional_global_conditioning import ( + AttachRegionalGlobalConditioningV3, + ) from .encode_prompt_batch_with_prompt_control import ( EncodePromptBatchWithPromptControl, ) + from .prepare_regional_lora_hooks import PrepareRegionalLoraHooksV3 from .schedule_and_encode_prompts_with_prompt_control import ( ScheduleAndEncodePromptsWithPromptControl, ) return [ *nodes, + AttachRegionalGlobalConditioningV3, EncodePromptBatchWithPromptControl, + PrepareRegionalLoraHooksV3, ScheduleAndEncodePromptsWithPromptControl, ] diff --git a/simple_syrup/nodes_v3/attach_regional_global_conditioning.py b/simple_syrup/nodes_v3/attach_regional_global_conditioning.py new file mode 100644 index 0000000..35a10e1 --- /dev/null +++ b/simple_syrup/nodes_v3/attach_regional_global_conditioning.py @@ -0,0 +1,71 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 internal node for regional global-prompt companions.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..runtime.regional_conditioning_companion import attach_global_companion + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class AttachRegionalGlobalConditioningV3(_ComfyNodeBase): + """Attach the hooked global share required by one regional LoRA.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the internal companion-conditioning schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.AttachRegionalGlobalConditioning", + display_name="Attach Regional Global Conditioning", + category="SimpleSyrup/Internal", + description=( + "Keeps a region's global and local prompt shares under the same " + "regional LoRA." + ), + inputs=[ + _comfy_io.Conditioning.Input( + "conditioning", + tooltip="Regional prompt conditioning to preserve.", + ), + _comfy_io.Conditioning.Input( + "global_conditioning", + tooltip=( + "Global prompt encoded with the same regional LoRA hooks." + ), + ), + ], + outputs=[ + _comfy_io.Conditioning.Output( + "conditioning", + tooltip="Regional conditioning carrying its hooked global share.", + ) + ], + ) + + @classmethod + def execute( + cls, + conditioning: object, + global_conditioning: object, + ) -> tuple[object]: + """Attach the global companion to one regional conditioning.""" + + return (attach_global_companion(conditioning, global_conditioning),) diff --git a/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py b/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py index b009edf..6b4eb85 100644 --- a/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py +++ b/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py @@ -47,8 +47,8 @@ class EncodePromptBatchWithPromptControl(_ComfyNodeBase): enable_expand=True, category="SimpleSyrup/Conditioning", description=( - "Encodes [SEP]-separated prompts into per-segment Prompt Control " - "conditioning batches with segment-local LoRA hooks." + "Encodes [SEP]-separated prompts into matched Prompt Control " + "batches, reusing each side's global text for missing regions." ), inputs=[ _comfy_io.Clip.Input( @@ -65,7 +65,8 @@ class EncodePromptBatchWithPromptControl(_ComfyNodeBase): default="", tooltip=( "Positive Prompt Control prompts in positional order; each " - "segment keeps its aligned LoRA hooks." + "segment keeps its aligned LoRA hooks, and global text " + "fills missing positive regions." ), ), _comfy_io.String.Input( @@ -74,7 +75,8 @@ class EncodePromptBatchWithPromptControl(_ComfyNodeBase): default="", tooltip=( "Negative Prompt Control prompts in positional order; each " - "segment shares hooks with the matching positive index." + "segment shares hooks with the matching positive index, " + "and global text fills missing negative regions." ), ), _comfy_io.String.Input( diff --git a/simple_syrup/nodes_v3/prepare_regional_lora_hooks.py b/simple_syrup/nodes_v3/prepare_regional_lora_hooks.py new file mode 100644 index 0000000..c534399 --- /dev/null +++ b/simple_syrup/nodes_v3/prepare_regional_lora_hooks.py @@ -0,0 +1,72 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 internal node for regional LoRA CLIP preparation.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..runtime.regional_lora_hooks import prepare_regional_lora_clip + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class PrepareRegionalLoraHooksV3(_ComfyNodeBase): + """Prepare only the CLIP work required by regional LoRA hooks.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the internal hook conversion schema.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.PrepareRegionalLoraHooks", + display_name="Prepare Regional LoRA Hooks", + category="SimpleSyrup/Internal", + description=( + "Keeps model-only regional LoRAs away from the text encoder while " + "preparing text-encoder patches when present." + ), + inputs=[ + _comfy_io.Clip.Input( + "clip", + tooltip="CLIP model used to encode this regional prompt.", + ), + _comfy_io.Hooks.Input( + "hooks", + tooltip=("Prompt-Control LoRA hooks to inspect and prepare."), + ), + ], + outputs=[ + _comfy_io.Clip.Output( + "clip", + tooltip=( + "Original CLIP for model-only LoRAs, or a hook-prepared " + "CLIP when text-encoder weights are present." + ), + ), + _comfy_io.Hooks.Output( + "hooks", + tooltip=("Regional LoRA hooks to attach after prompt encoding."), + ), + ], + ) + + @classmethod + def execute(cls, clip: Any, hooks: object) -> tuple[Any, object]: + """Return the appropriate encoding CLIP and unchanged model hooks.""" + + return prepare_regional_lora_clip(clip, hooks) diff --git a/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py b/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py index 994c2b0..ca4d234 100644 --- a/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py +++ b/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py @@ -50,7 +50,8 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): category="SimpleSyrup/Conditioning", description=( "Schedules Prompt-Control LoRAs and encodes prompts. With [SEP], " - "each conditioning entry keeps only its segment's LoRA hooks." + "both sides are matched using global text for missing regions and " + "share each segment's LoRA hooks." ), inputs=[ _comfy_io.Model.Input( @@ -85,7 +86,8 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): default="", tooltip=( "Positive Prompt-Control text; [SEP] creates ordered " - "conditioning entries with segment-local LoRA hooks." + "conditioning entries, and global text fills missing " + "positive regions." ), ), _comfy_io.String.Input( @@ -94,7 +96,8 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): default="", tooltip=( "Negative Prompt-Control text; [SEP] creates ordered " - "conditioning entries sharing each index's LoRA hooks." + "conditioning entries, and global text fills missing " + "negative regions." ), ), ], diff --git a/simple_syrup/runtime/prompt_control_batch_graph.py b/simple_syrup/runtime/prompt_control_batch_graph.py index 43f806a..67fd9b7 100644 --- a/simple_syrup/runtime/prompt_control_batch_graph.py +++ b/simple_syrup/runtime/prompt_control_batch_graph.py @@ -12,7 +12,10 @@ from ..domain.prompt_control_prompt import PreparedPromptSide from ..services.prompt_control_segment_planning_service import ( PromptControlSegmentPlanningService, ) -from .prompt_control_graph_adapter import PromptControlGraphAdapter +from .prompt_control_graph_adapter import ( + PromptControlGraphAdapter, + RegionalSegmentEncoding, +) PROMPT_CONTROL_MISSING_MESSAGE = ( "Encode Prompt Batch w/ Prompt Control requires comfyui-prompt-control. " @@ -42,7 +45,7 @@ class PromptControlBatchGraphBuilder: ) adapter = self.graph_adapter_class.load(PROMPT_CONTROL_MISSING_MESSAGE) expand: dict[str, dict[str, Any]] = {} - segment_clips = tuple( + segment_encodings = tuple( adapter.clip_with_hooks( clip=clip, lora_tags=hook.lora_tags, @@ -53,14 +56,14 @@ class PromptControlBatchGraphBuilder: ) positive = self._encode_side( plan.positive, - segment_clips=segment_clips, + segment_encodings=segment_encodings, adapter=adapter, expand=expand, label="positive", ) negative = self._encode_side( plan.negative, - segment_clips=segment_clips, + segment_encodings=segment_encodings, adapter=adapter, expand=expand, label="negative", @@ -71,7 +74,7 @@ class PromptControlBatchGraphBuilder: self, side: PreparedPromptSide, *, - segment_clips: tuple[Any, ...], + segment_encodings: tuple[RegionalSegmentEncoding, ...], adapter: PromptControlGraphAdapter, expand: dict[str, dict[str, Any]], label: str, @@ -80,13 +83,29 @@ class PromptControlBatchGraphBuilder: outputs = [ adapter.encode_segment( - clip=segment_clips[index], + segment=segment_encodings[index], text=chunk.text, expand=expand, label=f"{label} segment {index}", ) for index, chunk in enumerate(side.chunks) ] + for index in range(1, len(outputs)): + segment = segment_encodings[index] + if segment.hooks is None: + continue + global_companion = adapter.encode_segment( + segment=segment, + text=side.chunks[0].text, + expand=expand, + label=f"{label} segment {index} global companion", + ) + outputs[index] = adapter.attach_global_companion( + conditioning=outputs[index], + global_conditioning=global_companion, + expand=expand, + label=f"{label} segment {index}", + ) return adapter.pack_conditionings( outputs, expand=expand, diff --git a/simple_syrup/runtime/prompt_control_graph_adapter.py b/simple_syrup/runtime/prompt_control_graph_adapter.py index fca4347..fe4ce13 100644 --- a/simple_syrup/runtime/prompt_control_graph_adapter.py +++ b/simple_syrup/runtime/prompt_control_graph_adapter.py @@ -7,12 +7,21 @@ from __future__ import annotations import sys +from dataclasses import dataclass from importlib import import_module from typing import Any, cast from .prompt_control_availability import find_prompt_control_install +@dataclass(frozen=True) +class RegionalSegmentEncoding: + """Carry one segment's encoding CLIP and post-encode model hooks.""" + + clip: Any + hooks: Any | None = None + + class PromptControlGraphAdapter: """Build hook-aware lazy graph fragments behind one runtime boundary.""" @@ -76,7 +85,7 @@ class PromptControlGraphAdapter: def encode_segment( self, *, - clip: Any, + segment: RegionalSegmentEncoding, text: str, expand: dict[str, dict[str, Any]], label: str, @@ -84,7 +93,7 @@ class PromptControlGraphAdapter: """Encode one segment with a previously prepared CLIP link.""" output = self._lazy_nodes.PCLazyTextEncodeAdvanced.execute( - clip=clip, + clip=segment.clip, text=text, tags="", start=0.0, @@ -92,7 +101,24 @@ class PromptControlGraphAdapter: num_steps=0, ) self.merge_expand(expand, output.expand, f"{label} text encoding") - return output.args[0] + conditioning = output.args[0] + if segment.hooks is None: + return conditioning + + graph = self._graph_utils.GraphBuilder() + conditioned = graph.node( + "ConditioningSetProperties", + cond_NEW=conditioning, + hooks=segment.hooks, + strength=1.0, + set_cond_area="default", + ) + self.merge_expand( + expand, + cast(dict[str, dict[str, Any]], graph.finalize()), + f"{label} model hook attachment", + ) + return conditioned.out(0) def clip_with_hooks( self, @@ -101,26 +127,30 @@ class PromptControlGraphAdapter: lora_tags: str, expand: dict[str, dict[str, Any]], label: str, - ) -> Any: - """Return a CLIP link sharing one segment's hooks across prompt sides.""" + ) -> RegionalSegmentEncoding: + """Return separate encoding-CLIP and post-encode model-hook links.""" if not lora_tags: - return clip + return RegionalSegmentEncoding(clip=clip) graph = self._graph_utils.GraphBuilder() - hooks = graph.node("PCLoraHooksFromText", text=lora_tags) - hooked_clip = graph.node( - "SetClipHooks", + parsed_hooks = graph.node( + "PCLoraHooksFromText", + text=lora_tags, + ) + regional_hooks = graph.node( + "SimpleSyrup.PrepareRegionalLoraHooks", clip=clip, - hooks=hooks.out(0), - apply_to_conds=True, - schedule_clip=True, + hooks=parsed_hooks.out(0), ) self.merge_expand( expand, cast(dict[str, dict[str, Any]], graph.finalize()), f"{label} LoRA hooks", ) - return hooked_clip.out(0) + return RegionalSegmentEncoding( + clip=regional_hooks.out(0), + hooks=regional_hooks.out(1), + ) def pack_conditionings( self, @@ -152,6 +182,29 @@ class PromptControlGraphAdapter: ) return current.out(0) + def attach_global_companion( + self, + *, + conditioning: Any, + global_conditioning: Any, + expand: dict[str, dict[str, Any]], + label: str, + ) -> Any: + """Attach one hooked global prompt share to a regional conditioning.""" + + graph = self._graph_utils.GraphBuilder() + attached = graph.node( + "SimpleSyrup.AttachRegionalGlobalConditioning", + conditioning=conditioning, + global_conditioning=global_conditioning, + ) + self.merge_expand( + expand, + cast(dict[str, dict[str, Any]], graph.finalize()), + f"{label} global companion attachment", + ) + return attached.out(0) + def merge_expand( self, target: dict[str, dict[str, Any]], diff --git a/simple_syrup/runtime/prompt_control_schedule_encode_graph.py b/simple_syrup/runtime/prompt_control_schedule_encode_graph.py index b2d7ced..03aa60b 100644 --- a/simple_syrup/runtime/prompt_control_schedule_encode_graph.py +++ b/simple_syrup/runtime/prompt_control_schedule_encode_graph.py @@ -13,7 +13,10 @@ from ..services.prompt_control_segment_planning_service import ( PromptControlSegmentPlan, PromptControlSegmentPlanningService, ) -from .prompt_control_graph_adapter import PromptControlGraphAdapter +from .prompt_control_graph_adapter import ( + PromptControlGraphAdapter, + RegionalSegmentEncoding, +) PROMPT_CONTROL_MISSING_MESSAGE = ( "Schedule & Encode Prompts requires comfyui-prompt-control. " @@ -52,7 +55,7 @@ class PromptControlScheduleEncodeGraphBuilder: adapter=adapter, expand=expand, ) - segment_clips = self._segment_clips( + segment_encodings = self._segment_encodings( plan=plan, clip=encoding_clip, adapter=adapter, @@ -60,7 +63,7 @@ class PromptControlScheduleEncodeGraphBuilder: ) positive = self._encode_side( plan.positive, - segment_clips=segment_clips, + segment_encodings=segment_encodings, encode_style=encode_style, adapter=adapter, expand=expand, @@ -68,7 +71,7 @@ class PromptControlScheduleEncodeGraphBuilder: ) negative = self._encode_side( plan.negative, - segment_clips=segment_clips, + segment_encodings=segment_encodings, encode_style=encode_style, adapter=adapter, expand=expand, @@ -102,18 +105,18 @@ class PromptControlScheduleEncodeGraphBuilder: expand=expand, ) - def _segment_clips( + def _segment_encodings( self, *, plan: PromptControlSegmentPlan, clip: Any, adapter: PromptControlGraphAdapter, expand: dict[str, dict[str, Any]], - ) -> tuple[Any, ...]: - """Create one shared hooked CLIP link per batched segment index.""" + ) -> tuple[RegionalSegmentEncoding, ...]: + """Create one shared encoding context per batched segment index.""" if not plan.is_batched: - return (clip,) + return (RegionalSegmentEncoding(clip=clip),) return tuple( adapter.clip_with_hooks( clip=clip, @@ -128,7 +131,7 @@ class PromptControlScheduleEncodeGraphBuilder: self, side: PreparedPromptSide, *, - segment_clips: tuple[Any, ...], + segment_encodings: tuple[RegionalSegmentEncoding, ...], encode_style: str, adapter: PromptControlGraphAdapter, expand: dict[str, dict[str, Any]], @@ -138,13 +141,29 @@ class PromptControlScheduleEncodeGraphBuilder: outputs = [ adapter.encode_segment( - clip=segment_clips[index], + segment=segment_encodings[index], text=apply_encode_style(encode_style, chunk.text), expand=expand, label=f"{label} segment {index}", ) for index, chunk in enumerate(side.chunks) ] + for index in range(1, len(outputs)): + segment = segment_encodings[index] + if segment.hooks is None: + continue + global_companion = adapter.encode_segment( + segment=segment, + text=apply_encode_style(encode_style, side.chunks[0].text), + expand=expand, + label=f"{label} segment {index} global companion", + ) + outputs[index] = adapter.attach_global_companion( + conditioning=outputs[index], + global_conditioning=global_companion, + expand=expand, + label=f"{label} segment {index}", + ) return adapter.pack_conditionings( outputs, expand=expand, diff --git a/simple_syrup/runtime/regional_conditioning_companion.py b/simple_syrup/runtime/regional_conditioning_companion.py new file mode 100644 index 0000000..24c9791 --- /dev/null +++ b/simple_syrup/runtime/regional_conditioning_companion.py @@ -0,0 +1,64 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Carry hooked global conditioning alongside one regional conditioning entry.""" + +from __future__ import annotations + +from typing import Any, TypeAlias + +Conditioning: TypeAlias = list[list[Any]] +_GLOBAL_COMPANION_KEY = "simple_syrup.regional_global_companion" + + +def attach_global_companion( + conditioning: object, + global_conditioning: object, +) -> Conditioning: + """Attach one hooked global conditioning without changing Comfy's public type.""" + + local = _validate_conditioning(conditioning, "regional conditioning") + companion = _validate_conditioning( + global_conditioning, + "regional global companion", + ) + attached = _copy_conditioning(local) + attached[0][1][_GLOBAL_COMPANION_KEY] = _copy_conditioning(companion) + return attached + + +def detach_global_companion( + conditioning: Conditioning, +) -> tuple[Conditioning, Conditioning | None]: + """Remove and return an attached global companion before Comfy sampling.""" + + detached = _copy_conditioning(conditioning) + companion = detached[0][1].pop(_GLOBAL_COMPANION_KEY, None) + if companion is None: + return detached, None + return detached, _validate_conditioning( + companion, + "regional global companion", + ) + + +def _validate_conditioning(value: object, name: str) -> Conditioning: + """Validate the Comfy conditioning container used by the internal graph node.""" + + if not isinstance(value, list) or not value: + raise TypeError(f"{name} must be a non-empty CONDITIONING value.") + for index, item in enumerate(value): + if ( + not isinstance(item, list | tuple) + or len(item) != 2 + or not isinstance(item[1], dict) + ): + raise ValueError(f"{name} item {index} must contain a tensor and metadata.") + return value + + +def _copy_conditioning(conditioning: Conditioning) -> Conditioning: + """Copy conditioning containers and metadata without cloning tensors.""" + + return [[item[0], dict(item[1])] for item in conditioning] diff --git a/simple_syrup/runtime/regional_lora_hooks.py b/simple_syrup/runtime/regional_lora_hooks.py new file mode 100644 index 0000000..4929f4b --- /dev/null +++ b/simple_syrup/runtime/regional_lora_hooks.py @@ -0,0 +1,60 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Prepare regional LoRA hooks without needlessly cloning text encoders.""" + +from __future__ import annotations + +import logging +from importlib import import_module +from typing import Any + +LOGGER = logging.getLogger(__name__) + + +def prepare_regional_lora_clip(clip: Any, hooks: object) -> tuple[Any, object]: + """Prepare CLIP only when at least one hook contains CLIP-compatible weights.""" + + comfy_hooks = import_module("comfy.hooks") + if not isinstance(hooks, comfy_hooks.HookGroup): + raise TypeError("regional LoRA hooks must be a Comfy HOOKS value.") + matching_hook_count = _count_clip_patch_hooks(clip, hooks, comfy_hooks) + if matching_hook_count == 0: + LOGGER.debug( + "Regional LoRA hook preparation preserved the original CLIP because " + "the hook group contains no matching text-encoder patches." + ) + return clip, hooks + + LOGGER.debug( + "Regional LoRA hook preparation created a scheduled CLIP for %d " + "text-encoder hook group entries.", + matching_hook_count, + ) + prepared_clip = clip.clone(disable_dynamic=True) + prepared_clip.patcher.forced_hooks = hooks.clone() + prepared_clip.use_clip_schedule = True + prepared_clip.patcher.register_all_hook_patches( + hooks, + comfy_hooks.create_target_dict(comfy_hooks.EnumWeightTarget.Clip), + ) + return prepared_clip, hooks + + +def _count_clip_patch_hooks(clip: Any, hooks: Any, comfy_hooks: Any) -> int: + """Count enabled weight hooks that resolve to at least one CLIP model key.""" + + comfy_lora = import_module("comfy.lora") + key_map = comfy_lora.model_lora_keys_clip(clip.cond_stage_model, {}) + matching_hook_count = 0 + for hook in hooks.get_type(comfy_hooks.EnumHookType.Weight): + if hook._strength_clip == 0.0: + continue + if hook.need_weight_init: + loaded = comfy_lora.load_lora(hook.weights, key_map, log_missing=False) + else: + loaded = hook.weights_clip + if loaded: + matching_hook_count += 1 + return matching_hook_count diff --git a/simple_syrup/services/prompt_batch_encoding_service.py b/simple_syrup/services/prompt_batch_encoding_service.py new file mode 100644 index 0000000..59ccb50 --- /dev/null +++ b/simple_syrup/services/prompt_batch_encoding_service.py @@ -0,0 +1,59 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Encode aligned standard positive and negative prompt batches.""" + +from __future__ import annotations + +from typing import Any, Protocol + +from ..domain.conditioning_batch import ConditioningBatch, split_prompt_batch +from ..domain.prompt_segment_alignment import build_prompt_segment_alignment + + +class PromptBatchEncoder(Protocol): + """Encode ordered prompt text through a Comfy-compatible adapter.""" + + def encode_batch( + self, + clip: Any, + chunks: tuple[str, ...], + ) -> ConditioningBatch: + """Return one conditioning entry per ordered prompt chunk.""" + + +class PromptBatchEncodingService: + """Align two SEP prompt sides before delegating Comfy text encoding.""" + + def __init__(self, encoder: PromptBatchEncoder) -> None: + """Store the runtime encoding adapter for one node execution.""" + + self._encoder = encoder + + def encode( + self, + *, + clip: Any, + positive_prompt: str, + negative_prompt: str, + separator: str, + ) -> tuple[ConditioningBatch, ConditioningBatch]: + """Return positive and negative batches with matched segment counts.""" + + positive_chunks = split_prompt_batch(positive_prompt, separator) + negative_chunks = split_prompt_batch(negative_prompt, separator) + alignment = build_prompt_segment_alignment( + positive_count=len(positive_chunks), + negative_count=len(negative_chunks), + ) + return ( + self._encoder.encode_batch( + clip, + alignment.positive.materialize(positive_chunks), + ), + self._encoder.encode_batch( + clip, + alignment.negative.materialize(negative_chunks), + ), + ) diff --git a/simple_syrup/services/prompt_control_segment_planning_service.py b/simple_syrup/services/prompt_control_segment_planning_service.py index ae325c3..194ae2c 100644 --- a/simple_syrup/services/prompt_control_segment_planning_service.py +++ b/simple_syrup/services/prompt_control_segment_planning_service.py @@ -9,10 +9,15 @@ from __future__ import annotations from dataclasses import dataclass from ..domain.prompt_control_prompt import ( + PreparedPromptChunk, PreparedPromptSide, PromptSegmentHookPlan, prepare_prompt_side, ) +from ..domain.prompt_segment_alignment import ( + PromptSideAlignment, + build_prompt_segment_alignment, +) @dataclass(frozen=True) @@ -40,16 +45,21 @@ class PromptControlSegmentPlanningService: negative_prompt: str, separator: str, ) -> PromptControlSegmentPlan: - """Return a deterministic plan without padding either prompt side.""" + """Return matched prompt sides and one shared hook plan per position.""" - positive = prepare_prompt_side(positive_prompt, separator) - negative = prepare_prompt_side(negative_prompt, separator) - hook_count = max(len(positive.chunks), len(negative.chunks)) + authored_positive = prepare_prompt_side(positive_prompt, separator) + authored_negative = prepare_prompt_side(negative_prompt, separator) + alignment = build_prompt_segment_alignment( + positive_count=len(authored_positive.chunks), + negative_count=len(authored_negative.chunks), + ) + positive = self._align_side(authored_positive, alignment.positive) + negative = self._align_side(authored_negative, alignment.negative) hooks = tuple( PromptSegmentHookPlan( lora_tags=self._combined_lora_tags(positive, negative, index) ) - for index in range(hook_count) + for index in range(alignment.segment_count) ) return PromptControlSegmentPlan( positive=positive, @@ -57,6 +67,34 @@ class PromptControlSegmentPlanningService: hooks=hooks, ) + def _align_side( + self, + side: PreparedPromptSide, + alignment: PromptSideAlignment, + ) -> PreparedPromptSide: + """Build global-context regions without repeating global LoRA tags.""" + + chunks = tuple( + self._effective_chunk( + authored=source.authored, + source=source.resolve(side.chunks), + ) + for source in alignment.sources + ) + return PreparedPromptSide(chunks=chunks) + + def _effective_chunk( + self, + *, + authored: bool, + source: PreparedPromptChunk, + ) -> PreparedPromptChunk: + """Strip tags only when a chunk was synthesized from global text.""" + + if authored: + return source + return PreparedPromptChunk(text=source.text, lora_tags="") + def _combined_lora_tags( self, positive: PreparedPromptSide, @@ -66,8 +104,8 @@ class PromptControlSegmentPlanningService: """Join existing positive then negative tags for one segment index.""" tags: list[str] = [] - if index < len(positive.chunks) and positive.chunks[index].lora_tags: + if positive.chunks[index].lora_tags: tags.append(positive.chunks[index].lora_tags) - if index < len(negative.chunks) and negative.chunks[index].lora_tags: + if negative.chunks[index].lora_tags: tags.append(negative.chunks[index].lora_tags) return "\n".join(tags) diff --git a/simple_syrup/services/regional_conditioning_service.py b/simple_syrup/services/regional_conditioning_service.py index 6bdf33c..df5f757 100644 --- a/simple_syrup/services/regional_conditioning_service.py +++ b/simple_syrup/services/regional_conditioning_service.py @@ -16,10 +16,10 @@ from ..domain.regional_prompting import ( validate_regional_prompt_weight, ) from ..masking.regional_prompt_masks import ( - complementary_global_prompt_mask, prepare_regional_mask_batch, regional_mask, ) +from ..runtime.regional_conditioning_companion import detach_global_companion from ..shared.logging import get_logger Conditioning: TypeAlias = list[list[Any]] @@ -90,34 +90,47 @@ class RegionalConditioningService: entries[0], input_name=f"{input_name} global", ) - if not plan.pairs or regional_prompt_weight == 0.0: + if not plan.pairs: return self._copy_conditioning(global_conditioning) - global_mask = complementary_global_prompt_mask( - mask_batch, - tuple(pair.mask_index for pair in plan.pairs), - regional_prompt_weight, - ) - assembled = self._with_mask( - global_conditioning, - global_mask, - mask_strength=1.0, - ) + assembled = self._as_default(global_conditioning) for pair in plan.pairs: conditioning = self._validate_conditioning( entries[pair.conditioning_index], input_name=(f"{input_name} regional entry {pair.conditioning_index}"), ) + conditioning, global_companion = detach_global_companion(conditioning) mask = regional_mask(mask_batch, pair.mask_index) - assembled.extend( - self._with_mask( - conditioning, - mask, - mask_strength=regional_prompt_weight, + if global_companion is not None and regional_prompt_weight < 1.0: + assembled.extend( + self._with_mask( + global_companion, + mask, + mask_strength=1.0 - regional_prompt_weight, + ) ) - ) + if regional_prompt_weight > 0.0: + assembled.extend( + self._with_mask( + conditioning, + mask, + mask_strength=regional_prompt_weight, + ) + ) + if len(assembled) == len(global_conditioning): + return self._copy_conditioning(global_conditioning) return assembled + def _as_default(self, conditioning: Conditioning) -> Conditioning: + """Mark global conditioning to fill Comfy's remaining regional weight.""" + + default_conditioning: Conditioning = [] + for item in conditioning: + metadata = dict(item[1]) + metadata["default"] = True + default_conditioning.append([item[0], metadata]) + return default_conditioning + def _validate_conditioning( self, value: object, diff --git a/tests/test_encode_prompt_batch_node.py b/tests/test_encode_prompt_batch_node.py index 1974040..fc1e462 100644 --- a/tests/test_encode_prompt_batch_node.py +++ b/tests/test_encode_prompt_batch_node.py @@ -25,6 +25,7 @@ def test_encode_prompt_batch_contract() -> None: ) assert EncodePromptBatch.RETURN_NAMES == ("positive", "negative") assert EncodePromptBatch.CATEGORY == "SimpleSyrup/Conditioning" + assert "global" in EncodePromptBatch.DESCRIPTION.lower() assert list(inputs["required"]) == [ "clip", "positive_prompt", @@ -35,12 +36,14 @@ def test_encode_prompt_batch_contract() -> None: assert inputs["required"]["positive_prompt"][1]["default"] == "" assert inputs["required"]["negative_prompt"][1]["default"] == "" assert inputs["required"]["separator"][1]["default"] == "[SEP]" + assert "global" in inputs["required"]["positive_prompt"][1]["tooltip"].lower() + assert "global" in inputs["required"]["negative_prompt"][1]["tooltip"].lower() -def test_encode_prompt_batch_splits_and_encodes_each_chunk( +def test_encode_prompt_batch_aligns_missing_negative_chunks_to_global( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Standard encoder batches positive and negative chunks independently.""" + """Standard encoder creates matched negative regions from global text.""" monkeypatch.setattr(EncodePromptBatch, "encoder_class", _FakeEncoder) @@ -54,7 +57,7 @@ def test_encode_prompt_batch_splits_and_encodes_each_chunk( assert isinstance(positive, ConditioningBatch) assert isinstance(negative, ConditioningBatch) assert positive.entries == ("clip:face", "clip:hair") - assert negative.entries == ("clip:blur",) + assert negative.entries == ("clip:blur", "clip:blur") def test_encode_prompt_batch_encodes_blank_and_empty_chunks( @@ -73,10 +76,30 @@ def test_encode_prompt_batch_encodes_blank_and_empty_chunks( assert isinstance(positive, ConditioningBatch) assert isinstance(negative, ConditioningBatch) - assert positive.entries == ("clip:",) + assert positive.entries == ("clip:", "clip:") assert negative.entries == ("clip:bad", "clip:") +def test_encode_prompt_batch_uses_global_positive_for_missing_regions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Negative-authored regions receive matched global positive entries.""" + + monkeypatch.setattr(EncodePromptBatch, "encoder_class", _FakeEncoder) + + positive, negative = EncodePromptBatch().encode( + clip="clip", + positive_prompt="subject", + negative_prompt="bad [SEP] hands [SEP] text", + separator="[SEP]", + ) + + assert isinstance(positive, ConditioningBatch) + assert isinstance(negative, ConditioningBatch) + assert positive.entries == ("clip:subject",) * 3 + assert negative.entries == ("clip:bad", "clip:hands", "clip:text") + + class _FakeEncoder: """Fake prompt encoder for node tests.""" diff --git a/tests/test_encode_prompt_batch_with_prompt_control_node.py b/tests/test_encode_prompt_batch_with_prompt_control_node.py index 3314558..8bb31d5 100644 --- a/tests/test_encode_prompt_batch_with_prompt_control_node.py +++ b/tests/test_encode_prompt_batch_with_prompt_control_node.py @@ -20,6 +20,7 @@ def test_prompt_control_prompt_batch_node_schema() -> None: assert schema.display_name == "Encode Prompt Batch w/ Prompt Control" assert schema.enable_expand is True assert schema.category == "SimpleSyrup/Conditioning" + assert "global" in schema.description.lower() assert [output.io_type for output in schema.outputs] == [ "CONDITIONING_BATCH", "CONDITIONING_BATCH", @@ -48,3 +49,5 @@ def test_prompt_control_prompt_batch_input_types() -> None: ] assert inputs["required"]["clip"][0] == "CLIP" assert inputs["required"]["separator"][1]["default"] == "[SEP]" + assert "global" in inputs["required"]["positive_prompt"][1]["tooltip"].lower() + assert "global" in inputs["required"]["negative_prompt"][1]["tooltip"].lower() diff --git a/tests/test_prompt_control_batch_graph.py b/tests/test_prompt_control_batch_graph.py index f9ace8d..d901adb 100644 --- a/tests/test_prompt_control_batch_graph.py +++ b/tests/test_prompt_control_batch_graph.py @@ -103,21 +103,81 @@ def test_prompt_control_batch_graph_attaches_segment_local_lora_hooks( ) assert output.expand is not None - hook_nodes = [ + parsed_hook_nodes = [ node for node in output.expand.values() if node["class_type"] == "PCLoraHooksFromText" ] - assert [node["inputs"]["text"] for node in hook_nodes] == [ + assert [node["inputs"]["text"] for node in parsed_hook_nodes] == [ "\n", "", ] - assert [call["text"] for call in calls] == ["face ", "hair ", "blur ", "noise"] - assert calls[0]["clip"] == calls[2]["clip"] - assert calls[1]["clip"] == calls[3]["clip"] + regional_hook_nodes = [ + node + for node in output.expand.values() + if node["class_type"] == "SimpleSyrup.PrepareRegionalLoraHooks" + ] + assert len(regional_hook_nodes) == 2 + assert not any( + node["class_type"] == "SetClipHooks" for node in output.expand.values() + ) + attachment_nodes = [ + node + for node in output.expand.values() + if node["class_type"] == "ConditioningSetProperties" + ] + assert len(attachment_nodes) == 6 + companion_nodes = [ + node + for node in output.expand.values() + if node["class_type"] == "SimpleSyrup.AttachRegionalGlobalConditioning" + ] + assert len(companion_nodes) == 2 + assert [call["text"] for call in calls] == [ + "face ", + "hair ", + "face ", + "blur ", + "noise", + "blur ", + ] + assert calls[0]["clip"] == calls[3]["clip"] + assert calls[1]["clip"] == calls[4]["clip"] assert calls[0]["clip"] != calls[1]["clip"] +def test_prompt_control_batch_graph_matches_missing_negative_with_region_hooks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A sole global negative is re-encoded under each regional hook plan.""" + + calls = _install_fake_prompt_control(monkeypatch) + graph_utils = import_module("comfy_execution.graph_utils") + graph_utils.GraphBuilder.set_default_prefix("REGRESSION", 0, 0) + + PromptControlBatchGraphBuilder().build( + clip=[0, 0], + positive_prompt=("global [SEP] left [SEP] right"), + negative_prompt="bad quality", + separator="[SEP]", + ) + + assert [call["text"] for call in calls] == [ + "global", + "left ", + "right", + "global", + "bad quality", + "bad quality", + "bad quality", + "bad quality", + ] + assert calls[1]["clip"] == calls[5]["clip"] + assert calls[3]["clip"] == calls[7]["clip"] + assert calls[0]["clip"] == calls[4]["clip"] + assert calls[2]["clip"] == calls[6]["clip"] + + def test_prompt_control_batch_graph_reports_missing_prompt_control( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -152,6 +212,51 @@ def _install_fake_prompt_control( nodes_lazy = ModuleType("prompt_control.nodes_lazy") calls: list[dict[str, Any]] = [] + class FakePCLazyLoraLoaderAdvanced: + """Expand segment LoRAs through Comfy's native hook nodes.""" + + @staticmethod + def execute( + model: Any, + clip: Any, + text: str, + apply_hooks: bool, + tags: str, + start: float, + end: float, + num_steps: int, + ) -> Any: + """Return a hooked CLIP link built from native graph components.""" + + assert model is None + assert apply_hooks is True + assert tags == "" + assert start == 0.0 + assert end == 1.0 + assert num_steps == 0 + graph_utils = import_module("comfy_execution.graph_utils") + io = import_module("comfy_api.latest").io + graph = graph_utils.GraphBuilder() + hooks = graph.node( + "CreateHookLora", + lora_name=text, + strength_model=1.0, + strength_clip=1.0, + ) + hooked_clip = graph.node( + "SetClipHooks", + clip=clip, + hooks=hooks.out(0), + apply_to_conds=True, + schedule_clip=True, + ) + return io.NodeOutput( + None, + hooked_clip.out(0), + hooks.out(0), + expand=graph.finalize(), + ) + class FakePCLazyTextEncodeAdvanced: """Graph-expanding stand-in for Prompt Control's lazy text encoder.""" @@ -178,6 +283,7 @@ def _install_fake_prompt_control( ) return io.NodeOutput(node.out(0), expand=graph.finalize()) + cast(Any, nodes_lazy).PCLazyLoraLoaderAdvanced = FakePCLazyLoraLoaderAdvanced cast(Any, nodes_lazy).PCLazyTextEncodeAdvanced = FakePCLazyTextEncodeAdvanced cast(Any, prompt_control).nodes_lazy = nodes_lazy monkeypatch.setitem(sys.modules, "prompt_control", prompt_control) diff --git a/tests/test_prompt_control_schedule_encode_graph.py b/tests/test_prompt_control_schedule_encode_graph.py index 883e630..e4659bf 100644 --- a/tests/test_prompt_control_schedule_encode_graph.py +++ b/tests/test_prompt_control_schedule_encode_graph.py @@ -60,12 +60,12 @@ def test_schedule_encode_graph_builds_single_conditioning_outputs( ] -def test_schedule_encode_graph_packs_only_multichunk_sides( +def test_schedule_encode_graph_packs_both_sides_to_matched_segment_counts( monkeypatch: pytest.MonkeyPatch, ) -> None: - """A side with multiple chunks becomes a conditioning batch.""" + """A missing negative region is encoded and packed from global text.""" - _install_fake_prompt_control(monkeypatch) + calls = _install_fake_prompt_control(monkeypatch) output = PromptControlScheduleEncodeGraphBuilder().build( model=["model", 0], @@ -75,7 +75,13 @@ def test_schedule_encode_graph_packs_only_multichunk_sides( ) assert output.args[1] != ["encode_0", 0] - assert output.args[2] == ["encode_2", 0] + assert output.args[2] != ["encode_2", 0] + assert [call["text"] for call in calls["encode"]] == [ + "face", + "hair", + "blur", + "blur", + ] assert output.expand is not None pack_nodes = [ node @@ -85,9 +91,41 @@ def test_schedule_encode_graph_packs_only_multichunk_sides( assert [node["class_type"] for node in pack_nodes] == [ "SimpleSyrup.ConditioningBatchStart", "SimpleSyrup.ConditioningBatchAppend", + "SimpleSyrup.ConditioningBatchStart", + "SimpleSyrup.ConditioningBatchAppend", ] +def test_schedule_encode_graph_matches_lora_region_on_both_cfg_sides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regional LoRA hooks apply to positive and synthesized negative entries.""" + + calls = _install_fake_prompt_control(monkeypatch) + + PromptControlScheduleEncodeGraphBuilder().build( + model=["model", 0], + clip=["clip", 0], + positive_prompt="global [SEP] left [SEP] right", + negative_prompt="bad quality", + ) + + assert [call["text"] for call in calls["encode"]] == [ + "global", + "left ", + "right", + "global", + "bad quality", + "bad quality", + "bad quality", + "bad quality", + ] + assert calls["encode"][1]["clip"] == calls["encode"][5]["clip"] + assert calls["encode"][3]["clip"] == calls["encode"][7]["clip"] + assert calls["encode"][0]["clip"] == calls["encode"][4]["clip"] + assert calls["encode"][2]["clip"] == calls["encode"][6]["clip"] + + def test_schedule_encode_graph_keeps_loras_local_to_aligned_segments( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -105,23 +143,44 @@ def test_schedule_encode_graph_keeps_loras_local_to_aligned_segments( assert output.args[0] == ["model", 0] assert calls["lora"] == [] assert output.expand is not None - hook_nodes = [ + parsed_hook_nodes = [ node for node in output.expand.values() if node["class_type"] == "PCLoraHooksFromText" ] - assert [node["inputs"]["text"] for node in hook_nodes] == [ + assert [node["inputs"]["text"] for node in parsed_hook_nodes] == [ "\n", "\n", ] + regional_hook_nodes = [ + node + for node in output.expand.values() + if node["class_type"] == "SimpleSyrup.PrepareRegionalLoraHooks" + ] + assert len(regional_hook_nodes) == 2 clip_nodes = [ node for node in output.expand.values() if node["class_type"] == "SetClipHooks" ] - assert len(clip_nodes) == 2 - assert all(node["inputs"]["apply_to_conds"] is True for node in clip_nodes) - assert all(node["inputs"]["schedule_clip"] is True for node in clip_nodes) - assert calls["encode"][0]["clip"] == calls["encode"][2]["clip"] - assert calls["encode"][1]["clip"] == calls["encode"][3]["clip"] + assert clip_nodes == [] + attachment_nodes = [ + node + for node in output.expand.values() + if node["class_type"] == "ConditioningSetProperties" + ] + assert len(attachment_nodes) == 6 + assert all(node["inputs"]["strength"] == 1.0 for node in attachment_nodes) + assert all( + node["inputs"]["set_cond_area"] == "default" for node in attachment_nodes + ) + companion_nodes = [ + node + for node in output.expand.values() + if node["class_type"] == "SimpleSyrup.AttachRegionalGlobalConditioning" + ] + assert len(companion_nodes) == 2 + assert calls["encode"][0]["clip"] == calls["encode"][3]["clip"] + assert calls["encode"][1]["clip"] == calls["encode"][4]["clip"] + assert calls["encode"][2]["clip"] == calls["encode"][5]["clip"] assert calls["encode"][0]["clip"] != calls["encode"][1]["clip"] @@ -176,7 +235,11 @@ def _install_fake_prompt_control( prompt_control = ModuleType("prompt_control") nodes_lazy = ModuleType("prompt_control.nodes_lazy") io = import_module("comfy_api.latest").io - calls: dict[str, list[dict[str, Any]]] = {"lora": [], "encode": []} + calls: dict[str, list[dict[str, Any]]] = { + "lora": [], + "hook": [], + "encode": [], + } class FakePCLazyLoraLoaderAdvanced: """Prompt-Control LoRA scheduler test double.""" @@ -199,6 +262,29 @@ def _install_fake_prompt_control( assert start == 0.0 assert end == 1.0 assert num_steps == 0 + if model is None: + calls["hook"].append({"clip": clip, "text": text}) + graph_utils = import_module("comfy_execution.graph_utils") + graph = graph_utils.GraphBuilder() + hooks = graph.node( + "CreateHookLora", + lora_name=text, + strength_model=1.0, + strength_clip=1.0, + ) + hooked_clip = graph.node( + "SetClipHooks", + clip=clip, + hooks=hooks.out(0), + apply_to_conds=True, + schedule_clip=True, + ) + return io.NodeOutput( + None, + hooked_clip.out(0), + hooks.out(0), + expand=graph.finalize(), + ) side = "positive" if not calls["lora"] else "negative" calls["lora"].append({"model": model, "clip": clip, "text": text}) node_id = "duplicate_lora" if duplicate_lora_ids else f"lora_{side}" diff --git a/tests/test_prompt_control_segment_planning_service.py b/tests/test_prompt_control_segment_planning_service.py index 69903fd..4b62549 100644 --- a/tests/test_prompt_control_segment_planning_service.py +++ b/tests/test_prompt_control_segment_planning_service.py @@ -38,8 +38,8 @@ def test_planner_combines_lora_tags_only_at_aligned_indexes() -> None: ] -def test_planner_preserves_explicit_empties_without_padding_shorter_side() -> None: - """Empty chunks remain positional while unequal side lengths remain unequal.""" +def test_planner_preserves_empties_and_fills_missing_side_from_global() -> None: + """Authored empties remain while missing negative positions reuse global text.""" plan = PromptControlSegmentPlanningService().prepare( positive_prompt="global [SEP] [SEP] right ", @@ -52,10 +52,62 @@ def test_planner_preserves_explicit_empties_without_padding_shorter_side() -> No "", "right ", ] - assert [chunk.text for chunk in plan.negative.chunks] == ["negative"] + assert [chunk.text for chunk in plan.negative.chunks] == [ + "negative", + "negative", + "negative", + ] assert [hook.lora_tags for hook in plan.hooks] == ["", "", ""] +def test_planner_fallback_text_does_not_repeat_global_lora_tags() -> None: + """Synthetic segments inherit global text but not global SEP-local tags.""" + + plan = PromptControlSegmentPlanningService().prepare( + positive_prompt="global [SEP] left [SEP] right", + negative_prompt="bad ", + separator="[SEP]", + ) + + assert [chunk.text for chunk in plan.negative.chunks] == ["bad ", "bad ", "bad "] + assert [chunk.lora_tags for chunk in plan.negative.chunks] == [ + "", + "", + "", + ] + assert [hook.lora_tags for hook in plan.hooks] == [ + "", + "", + "", + ] + + +def test_planner_fills_missing_positive_side_symmetrically() -> None: + """Negative-authored regions reuse global positive text with local hooks.""" + + plan = PromptControlSegmentPlanningService().prepare( + positive_prompt="subject ", + negative_prompt="bad [SEP] hands [SEP] text", + separator="[SEP]", + ) + + assert [chunk.text for chunk in plan.positive.chunks] == [ + "subject ", + "subject ", + "subject ", + ] + assert [chunk.lora_tags for chunk in plan.positive.chunks] == [ + "", + "", + "", + ] + assert [hook.lora_tags for hook in plan.hooks] == [ + "", + "", + "", + ] + + def test_planner_marks_single_segment_prompts_as_unbatched() -> None: """No-SEP prompts retain the compatibility path used by the scheduler.""" diff --git a/tests/test_prompt_segment_alignment.py b/tests/test_prompt_segment_alignment.py new file mode 100644 index 0000000..f35d7a4 --- /dev/null +++ b/tests/test_prompt_segment_alignment.py @@ -0,0 +1,76 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for authored and fallback SEP segment alignment.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.prompt_segment_alignment import ( + build_prompt_segment_alignment, +) + + +def test_alignment_reuses_global_value_for_missing_negative_segments() -> None: + """Missing negative positions resolve to the authored global entry.""" + + plan = build_prompt_segment_alignment(positive_count=3, negative_count=1) + + assert plan.segment_count == 3 + assert plan.positive.materialize(("global", "left", "right")) == ( + "global", + "left", + "right", + ) + assert plan.negative.materialize(("bad",)) == ("bad", "bad", "bad") + assert [source.authored for source in plan.negative.sources] == [True, False, False] + assert [source.source_index for source in plan.negative.sources] == [0, 0, 0] + + +def test_alignment_is_symmetric_for_missing_positive_segments() -> None: + """Missing positive positions use global positive rather than a prior region.""" + + plan = build_prompt_segment_alignment(positive_count=1, negative_count=3) + + assert plan.positive.materialize(("global",)) == ( + "global", + "global", + "global", + ) + assert plan.negative.materialize(("bad", "hands", "text")) == ( + "bad", + "hands", + "text", + ) + + +def test_alignment_preserves_authored_empty_segments() -> None: + """An authored empty position remains empty instead of falling back.""" + + plan = build_prompt_segment_alignment(positive_count=3, negative_count=1) + + assert plan.positive.materialize(("global", "", "right")) == ( + "global", + "", + "right", + ) + assert plan.positive.sources[1].authored is True + + +@pytest.mark.parametrize( + ("positive_count", "negative_count"), + [(0, 1), (1, 0), (-1, 1), (1, -1)], +) +def test_alignment_rejects_sides_without_a_global_segment( + positive_count: int, + negative_count: int, +) -> None: + """Each side must expose index 0 before fallback can be planned.""" + + with pytest.raises(ValueError, match="at least one authored segment"): + build_prompt_segment_alignment( + positive_count=positive_count, + negative_count=negative_count, + ) diff --git a/tests/test_regional_conditioning_companion.py b/tests/test_regional_conditioning_companion.py new file mode 100644 index 0000000..b538d9b --- /dev/null +++ b/tests/test_regional_conditioning_companion.py @@ -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 + +"""Tests for internal regional global-conditioning companions.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.runtime.regional_conditioning_companion import ( + attach_global_companion, + detach_global_companion, +) + + +def test_attach_and_detach_preserve_conditioning_without_mutation() -> None: + """The internal companion round trip copies containers and metadata.""" + + local = [["local tensor", {"hooks": "regional", "local": True}]] + global_conditioning = [["global tensor", {"hooks": "regional", "global": True}]] + + attached = attach_global_companion(local, global_conditioning) + detached, companion = detach_global_companion(attached) + + assert detached == local + assert companion == global_conditioning + assert attached is not local + assert detached is not attached + assert companion is not global_conditioning + assert "simple_syrup.regional_global_companion" not in local[0][1] + + +def test_detach_without_companion_returns_a_clean_copy() -> None: + """Ordinary conditioning remains ordinary after companion inspection.""" + + conditioning = [["tensor", {"source": "ordinary"}]] + + detached, companion = detach_global_companion(conditioning) + + assert detached == conditioning + assert detached is not conditioning + assert companion is None + + +@pytest.mark.parametrize("value", [None, [], "conditioning"]) +def test_attach_rejects_invalid_conditioning(value: object) -> None: + """The internal node rejects empty or non-conditioning graph values.""" + + with pytest.raises(TypeError, match="CONDITIONING"): + attach_global_companion(value, [["global", {}]]) diff --git a/tests/test_regional_conditioning_service.py b/tests/test_regional_conditioning_service.py index 08b8295..13e36ec 100644 --- a/tests/test_regional_conditioning_service.py +++ b/tests/test_regional_conditioning_service.py @@ -10,6 +10,9 @@ import pytest import torch from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.runtime.regional_conditioning_companion import ( + attach_global_companion, +) from simple_syrup.services.regional_conditioning_service import ( RegionalConditioningService, ) @@ -75,16 +78,10 @@ def test_batches_pair_global_and_regions_independently() -> None: "negative global", "negative region 0", ] - assert torch.equal( - assembled_positive[0][1]["mask"], - torch.full((1, 3, 3), 0.25), - ) - assert torch.equal( - assembled_negative[0][1]["mask"], - torch.ones((1, 3, 3)), - ) - assert assembled_positive[0][1]["mask_strength"] == 1.0 - assert assembled_negative[0][1]["mask_strength"] == 1.0 + assert assembled_positive[0][1]["default"] is True + assert assembled_negative[0][1]["default"] is True + assert "mask" not in assembled_positive[0][1] + assert "mask" not in assembled_negative[0][1] assert torch.equal(assembled_positive[1][1]["mask"], masks[0:1]) assert torch.equal(assembled_positive[2][1]["mask"], masks[1:2]) assert torch.equal(assembled_negative[1][1]["mask"], masks[0:1]) @@ -169,6 +166,42 @@ def test_mask_composition_preserves_segment_lora_hook_metadata() -> None: assert assembled[1][1]["other"] == "region metadata" +@pytest.mark.parametrize( + ("regional_prompt_weight", "expected_sources", "expected_strengths"), + [ + (0.0, ["global", "hooked global"], [1.0]), + (0.5, ["global", "hooked global", "region"], [0.5, 0.5]), + (1.0, ["global", "region"], [1.0]), + ], +) +def test_hooked_global_companion_keeps_lora_full_while_prompt_weight_changes( + regional_prompt_weight: float, + expected_sources: list[str], + expected_strengths: list[float], +) -> None: + """Global and local prompt shares use one regional LoRA model state.""" + + regional = attach_global_companion( + [["region", {"hooks": "regional hooks"}]], + [["hooked global", {"hooks": "regional hooks"}]], + ) + + assembled, _ = RegionalConditioningService().assemble( + positive=ConditioningBatch((_conditioning("global"), regional)), + negative=_conditioning("negative"), + masks=torch.ones((1, 2, 2)), + regional_prompt_weight=regional_prompt_weight, + region_mask_feather=0, + ) + + assert [item[0] for item in assembled] == expected_sources + assert [item[1]["mask_strength"] for item in assembled[1:]] == expected_strengths + assert all(item[1]["hooks"] == "regional hooks" for item in assembled[1:]) + assert all( + "simple_syrup.regional_global_companion" not in item[1] for item in assembled + ) + + def test_zero_regional_prompt_weight_returns_only_unchanged_global_entries() -> None: """The zero endpoint disables regional conditioning completely.""" @@ -200,12 +233,40 @@ def test_full_regional_prompt_weight_complements_global_inside_mask() -> None: region_mask_feather=0, ) - assert torch.equal(positive[0][1]["mask"], 1.0 - mask) - assert positive[0][1]["mask_strength"] == 1.0 + assert positive[0][1]["default"] is True + assert "mask" not in positive[0][1] assert torch.equal(positive[1][1]["mask"], mask) assert positive[1][1]["mask_strength"] == 1.0 +@pytest.mark.parametrize("regional_prompt_weight", [0.25, 0.5, 1.0]) +def test_matched_global_negative_retains_full_regional_influence( + regional_prompt_weight: float, +) -> None: + """Global and fallback negative shares sum to full strength in a region.""" + + negative = ConditioningBatch( + (_conditioning("global negative"), _conditioning("global negative")) + ) + + _, assembled_negative = RegionalConditioningService().assemble( + positive=ConditioningBatch( + (_conditioning("global positive"), _conditioning("regional positive")) + ), + negative=negative, + masks=torch.ones((1, 2, 2)), + regional_prompt_weight=regional_prompt_weight, + region_mask_feather=0, + ) + + assert assembled_negative[0][1]["default"] is True + assert assembled_negative[1][1]["mask_strength"] == regional_prompt_weight + assert torch.equal( + assembled_negative[1][1]["mask"], + torch.ones((1, 2, 2)), + ) + + @pytest.mark.parametrize("weight", [-0.01, 1.01, float("nan")]) def test_invalid_regional_prompt_weight_fails_before_mask_processing( weight: float, diff --git a/tests/test_regional_lora_hooks.py b/tests/test_regional_lora_hooks.py new file mode 100644 index 0000000..4b64a10 --- /dev/null +++ b/tests/test_regional_lora_hooks.py @@ -0,0 +1,149 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for regional LoRA CLIP preparation.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import comfy.hooks +import comfy.lora +import pytest + +from simple_syrup.runtime.regional_lora_hooks import prepare_regional_lora_clip + + +class _FakeClip: + """Record whether regional preparation clones the text encoder.""" + + def __init__(self) -> None: + """Create a clip and its minimal patcher collaboration.""" + + self.cond_stage_model = object() + self.registrations: list[tuple[Any, Any]] = [] + self.patcher = SimpleNamespace( + forced_hooks=None, + register_all_hook_patches=self._register_hooks, + ) + self.use_clip_schedule = False + self.clone_calls: list[bool] = [] + + def clone(self, disable_dynamic: bool = False) -> _FakeClip: + """Return a distinct clip while recording clone policy.""" + + self.clone_calls.append(disable_dynamic) + clone = _FakeClip() + clone.clone_calls = self.clone_calls + clone.registrations = self.registrations + clone.patcher.register_all_hook_patches = clone._register_hooks + return clone + + def _register_hooks(self, hooks: Any, target: Any) -> None: + """Record native hook registration on the prepared clip.""" + + self.registrations.append((hooks, target)) + + +def test_model_only_hooks_preserve_original_clip( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A diffusion-only LoRA never clones or patches the text encoder.""" + + monkeypatch.setattr(comfy.lora, "model_lora_keys_clip", lambda model, keys: keys) + monkeypatch.setattr( + comfy.lora, + "load_lora", + lambda weights, key_map, log_missing: {}, + ) + hooks = comfy.hooks.create_hook_lora( + {"diffusion_model.layer.lora_A.weight": object()}, + strength_model=1.0, + strength_clip=1.0, + ) + clip = _FakeClip() + + prepared_clip, prepared_hooks = prepare_regional_lora_clip(clip, hooks) + + assert prepared_clip is clip + assert prepared_hooks is hooks + assert clip.clone_calls == [] + + +def test_clip_hooks_prepare_a_scheduled_clip( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A LoRA with matching text-encoder weights retains native CLIP hooks.""" + + monkeypatch.setattr( + comfy.lora, + "model_lora_keys_clip", + lambda model, keys: {"clip_key": "source_key"}, + ) + monkeypatch.setattr( + comfy.lora, + "load_lora", + lambda weights, key_map, log_missing: {"clip.weight": object()}, + ) + hooks = cast( + Any, + comfy.hooks.create_hook_lora( + {"clip.source.lora_A.weight": object()}, + strength_model=1.0, + strength_clip=0.75, + ), + ) + clip = _FakeClip() + prepared_clip, prepared_hooks = prepare_regional_lora_clip(clip, hooks) + + assert prepared_clip is not clip + assert prepared_hooks is hooks + assert clip.clone_calls == [True] + assert prepared_clip.use_clip_schedule is True + assert prepared_clip.patcher.forced_hooks is not hooks + forced_hooks = cast(Any, prepared_clip.patcher.forced_hooks) + source_hooks = cast(Any, hooks) + forced_hook = cast(Any, forced_hooks.hooks[0]) + source_hook = cast(Any, source_hooks.hooks[0]) + assert forced_hook.hook_ref is source_hook.hook_ref + assert len(clip.registrations) == 1 + assert clip.registrations[0][0] is hooks + + +def test_zero_strength_clip_hooks_preserve_original_clip( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Disabled CLIP strength avoids matching and cloning the text encoder.""" + + monkeypatch.setattr( + comfy.lora, + "model_lora_keys_clip", + lambda model, keys: {"clip_key": "source_key"}, + ) + + def fail_load_lora(*args: object, **kwargs: object) -> object: + """Fail if disabled CLIP hooks attempt to resolve weights.""" + + raise AssertionError("disabled CLIP hook should not load weights") + + monkeypatch.setattr(comfy.lora, "load_lora", fail_load_lora) + hooks = comfy.hooks.create_hook_lora( + {"clip.source.lora_A.weight": object()}, + strength_model=1.0, + strength_clip=0.0, + ) + clip = _FakeClip() + + prepared_clip, _ = prepare_regional_lora_clip(clip, hooks) + + assert prepared_clip is clip + assert clip.clone_calls == [] + + +def test_prepare_regional_lora_clip_rejects_non_hook_values() -> None: + """Invalid graph values fail before CLIP or model registration.""" + + with pytest.raises(TypeError, match="Comfy HOOKS"): + prepare_regional_lora_clip(_FakeClip(), object()) diff --git a/tests/test_regional_prompt_masks.py b/tests/test_regional_prompt_masks.py index 31b1b99..71471e6 100644 --- a/tests/test_regional_prompt_masks.py +++ b/tests/test_regional_prompt_masks.py @@ -10,40 +10,36 @@ import pytest import torch from simple_syrup.masking.regional_prompt_masks import ( - complementary_global_prompt_mask, + prepare_regional_mask_batch, + regional_mask, ) -def test_complementary_global_mask_uses_clamped_accumulated_coverage() -> None: - """Overlapping regions accumulate while global coverage remains normalized.""" +def test_prepare_regional_mask_batch_clamps_without_mutating_input() -> None: + """Mask preparation normalizes authored values on a separate tensor.""" - masks = torch.tensor( - [ - [[1.0, 1.0, 0.0]], - [[0.0, 1.0, 1.0]], - ] - ) + masks = torch.tensor([[[-1.0, 0.5, 2.0]]]) + original = masks.clone() - global_mask = complementary_global_prompt_mask(masks, (0, 1), 0.5) - regional_total = masks.sum(dim=0, keepdim=True) * 0.5 - global_share = global_mask / (global_mask + regional_total) + prepared = prepare_regional_mask_batch(masks, feather=0) - assert torch.equal(global_mask, torch.tensor([[[0.5, 0.5, 0.5]]])) - assert torch.allclose(global_share, torch.tensor([[[0.5, 1.0 / 3.0, 0.5]]])) + assert torch.equal(prepared, torch.tensor([[[0.0, 0.5, 1.0]]])) + assert torch.equal(masks, original) -def test_complementary_global_mask_uses_only_paired_indices() -> None: - """Extra authored masks do not reduce global prompt influence.""" +def test_regional_mask_returns_one_ordered_batch_entry() -> None: + """Positional selection preserves authored mask order and BHW shape.""" masks = torch.stack([torch.zeros((2, 2)), torch.ones((2, 2))]) - global_mask = complementary_global_prompt_mask(masks, (0,), 1.0) + selected = regional_mask(masks, 1) - assert torch.equal(global_mask, torch.ones((1, 2, 2))) + assert selected.shape == (1, 2, 2) + assert torch.equal(selected, masks[1:2]) -def test_complementary_global_mask_requires_a_regional_pair() -> None: - """Coverage cannot be calculated without a paired regional prompt.""" +def test_regional_mask_rejects_out_of_range_index() -> None: + """Selection fails explicitly when prompt and mask planning diverge.""" - with pytest.raises(ValueError, match="at least one regional mask"): - complementary_global_prompt_mask(torch.ones((1, 2, 2)), (), 0.5) + with pytest.raises(IndexError, match="out of range"): + regional_mask(torch.ones((1, 2, 2)), 1) diff --git a/tests/test_regional_prompt_workflow.py b/tests/test_regional_prompt_workflow.py index 0875f3d..97dcbb9 100644 --- a/tests/test_regional_prompt_workflow.py +++ b/tests/test_regional_prompt_workflow.py @@ -133,10 +133,15 @@ def test_sep_prompts_and_disk_masks_flow_into_regional_sampler( assert [item[0] for item in assembled_negative] == [ "global negative", "left negative", + "global negative", ] assert torch.equal(assembled_positive[1][1]["mask"], masks[0:1]) assert torch.equal(assembled_positive[2][1]["mask"], masks[1:2]) assert assembled_positive[1][1]["mask_strength"] == 0.8 assert assembled_positive[2][1]["mask_strength"] == 0.8 assert assembled_negative[1][1]["mask_strength"] == 0.8 + assert torch.equal(assembled_negative[2][1]["mask"], masks[1:2]) + assert assembled_negative[2][1]["mask_strength"] == 0.8 + assert assembled_negative[0][1]["default"] is True + assert "mask" not in assembled_negative[0][1] assert call["latent_image"]["samples"].shape[0] == 2 diff --git a/tests/test_registration.py b/tests/test_registration.py index 5c2f4c6..bbd36c8 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -60,7 +60,9 @@ BASE_NODE_IDS = [ ] PROMPT_CONTROL_NODE_IDS = [ + "SimpleSyrup.AttachRegionalGlobalConditioning", "SimpleSyrup.EncodePromptBatchWithPromptControl", + "SimpleSyrup.PrepareRegionalLoraHooks", "SimpleSyrup.ScheduleAndEncodePromptsWithPromptControl", ] diff --git a/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py b/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py index 0c54e30..3f499bd 100644 --- a/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py +++ b/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py @@ -140,6 +140,7 @@ def test_schedule_and_encode_prompt_control_node_schema() -> None: assert schema.display_name == "Schedule & Encode Prompts" assert schema.enable_expand is True assert schema.category == "SimpleSyrup/Conditioning" + assert "global" in schema.description.lower() assert [output.io_type for output in schema.outputs] == [ "MODEL", "CONDITIONING,CONDITIONING_BATCH", From 36081d7771e1dd7cf524d22e3e4f2d2dd3b7c45d Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Fri, 31 Jul 2026 22:08:15 -0400 Subject: [PATCH 25/40] feat(segmentation): add SAM-guided tiled diffusion --- simple_syrup/domain/segs_tiled_diffusion.py | 505 ++++++++++++++++++ simple_syrup/domain/tiled_diffusion.py | 7 +- .../nodes/ksampler_tiled_diffusion.py | 15 +- simple_syrup/nodes/segs_from_sam_output.py | 120 +++++ simple_syrup/nodes_v3/__init__.py | 2 + simple_syrup/nodes_v3/legacy_node_wrappers.py | 10 + .../runtime/mixture_of_diffusers_sampling.py | 38 +- simple_syrup/runtime/model_catalog.py | 19 + .../runtime/multidiffusion_sampling.py | 33 +- .../runtime/sam_automatic_segmenter.py | 253 +++++++++ simple_syrup/runtime/sam_loader.py | 26 +- simple_syrup/runtime/tiled_sampling.py | 67 +++ .../services/segs_from_sam_output_service.py | 372 +++++++++++++ .../tiled_diffusion_sampling_service.py | 166 +++++- tests/test_ksampler_tiled_diffusion_node.py | 5 + tests/test_registration.py | 1 + tests/test_sam_automatic_segmenter.py | 131 +++++ tests/test_sam_loader.py | 39 ++ tests/test_sam_model_loader_node.py | 1 + tests/test_segs_from_sam_output_node.py | 97 ++++ tests/test_segs_from_sam_output_service.py | 180 +++++++ tests/test_segs_tiled_diffusion.py | 189 +++++++ .../test_tiled_diffusion_sampling_service.py | 49 +- tests/test_tiled_sampling_runtime.py | 24 + 24 files changed, 2331 insertions(+), 18 deletions(-) create mode 100644 simple_syrup/domain/segs_tiled_diffusion.py create mode 100644 simple_syrup/nodes/segs_from_sam_output.py create mode 100644 simple_syrup/runtime/sam_automatic_segmenter.py create mode 100644 simple_syrup/services/segs_from_sam_output_service.py create mode 100644 tests/test_sam_automatic_segmenter.py create mode 100644 tests/test_segs_from_sam_output_node.py create mode 100644 tests/test_segs_from_sam_output_service.py create mode 100644 tests/test_segs_tiled_diffusion.py diff --git a/simple_syrup/domain/segs_tiled_diffusion.py b/simple_syrup/domain/segs_tiled_diffusion.py new file mode 100644 index 0000000..89e5a4a --- /dev/null +++ b/simple_syrup/domain/segs_tiled_diffusion.py @@ -0,0 +1,505 @@ +# 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 + +from dataclasses import dataclass + +import torch +import torch.nn.functional as functional + +from .segs import NativeSegs, Segment, coerce_segs +from .tiled_diffusion import ( + LatentTile, + TiledDiffusionPlan, + batch_latent_tiles, + build_tiled_diffusion_plan, +) + + +@dataclass(frozen=True) +class _OwnershipCore: + """Represent a non-overlapping latent ownership region before window placement.""" + + mask: torch.Tensor + bounds: tuple[int, int, int, int] + area: int + + +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, +) -> 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. + """ + + 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, + ) + native_segs = coerce_segs(segs) + _validate_aspect_ratio(native_segs, latent_height, latent_width) + ownership = _build_ownership_cores( + native_segs, + 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_cores = tuple( + split_core + for core in ownership + for split_core in _split_core( + core, + max_width=max_core_width, + max_height=max_core_height, + ) + ) + merged_cores = _merge_small_cores( + split_cores, + 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 merged_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_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 _build_ownership_cores( + segs: NativeSegs, + *, + latent_height: int, + latent_width: int, +) -> tuple[_OwnershipCore, ...]: + """Resolve overlapping SEGS into one 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) + cores: list[_OwnershipCore] = [] + for index in ranked_indexes: + owned = torch.logical_and(segment_masks[index], torch.logical_not(occupied)) + if bool(owned.any()): + cores.append(_core_from_mask(owned)) + occupied = torch.logical_or(occupied, segment_masks[index]) + background = torch.logical_not(occupied) + if bool(background.any()): + cores.append(_core_from_mask(background)) + if cores: + return tuple(cores) + return ( + _core_from_mask(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.""" + + 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 = ( + torch.as_tensor(segment.cropped_mask, dtype=torch.float32).detach().cpu() + ) + if local_mask.ndim == 3 and int(local_mask.shape[0]) == 1: + local_mask = local_mask.squeeze(0) + if local_mask.shape != (crop.height, crop.width): + raise ValueError( + "SEGS-guided tiled diffusion requires each cropped_mask to match its " + "crop_region." + ) + 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.bool) + 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 >= 0.5 + ) + return latent_mask + + +def _split_core( + core: _OwnershipCore, + *, + max_width: int, + max_height: int, +) -> tuple[_OwnershipCore, ...]: + """Recursively divide a core into balanced pieces that fit 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 SEGS-guided tile core.") + return first, second + + +def _merge_small_cores( + cores: tuple[_OwnershipCore, ...], + *, + max_width: int, + max_height: int, +) -> tuple[_OwnershipCore, ...]: + """Greedily combine small 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 index 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("SEGS-guided 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 the 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 _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)) + + +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("SEGS-guided 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 one 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, + ) diff --git a/simple_syrup/domain/tiled_diffusion.py b/simple_syrup/domain/tiled_diffusion.py index e9e2951..be5714e 100644 --- a/simple_syrup/domain/tiled_diffusion.py +++ b/simple_syrup/domain/tiled_diffusion.py @@ -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]: @@ -83,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, @@ -214,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]: diff --git a/simple_syrup/nodes/ksampler_tiled_diffusion.py b/simple_syrup/nodes/ksampler_tiled_diffusion.py index ada4761..4454e3a 100644 --- a/simple_syrup/nodes/ksampler_tiled_diffusion.py +++ b/simple_syrup/nodes/ksampler_tiled_diffusion.py @@ -153,7 +153,18 @@ class KSamplerTiledDiffusion: "tooltip": tooltips.LATENT_TILE_BATCH_SIZE, }, ), - } + }, + "optional": { + "segs": ( + "SEGS", + { + "tooltip": ( + "Optional image regions that guide irregular tile " + "boundaries while preserving the configured overlap." + ), + }, + ), + }, } def sample( @@ -173,6 +184,7 @@ class KSamplerTiledDiffusion: latent_tile_height: int = 128, latent_tile_overlap: int = 16, latent_tile_batch_size: int = 4, + segs: object | None = None, ) -> tuple[Latent]: """Sample a latent with the selected tiled diffusion method.""" @@ -193,5 +205,6 @@ class KSamplerTiledDiffusion: latent_tile_overlap=latent_tile_overlap, latent_tile_batch_size=latent_tile_batch_size, preview_context=None, + segs=segs, ) return (output,) diff --git a/simple_syrup/nodes/segs_from_sam_output.py b/simple_syrup/nodes/segs_from_sam_output.py new file mode 100644 index 0000000..c6d1e80 --- /dev/null +++ b/simple_syrup/nodes/segs_from_sam_output.py @@ -0,0 +1,120 @@ +# 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 automatic SEGS from a SAM model.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, ClassVar + +from ..masking.segs_mask_ops import iter_single_images, validate_image_batch +from ..runtime.progress import PhaseProgressReporter, create_comfy_phase_progress +from ..services.segs_from_sam_output_service import SEGSFromSAMOutputService + + +class SEGSFromSAMOutput: + """Generate reusable unprompted SEGS from a connected SAM model.""" + + service_class: ClassVar[type[SEGSFromSAMOutputService]] = SEGSFromSAMOutputService + progress_factory: ClassVar[Callable[..., PhaseProgressReporter]] = ( + create_comfy_phase_progress + ) + + RETURN_TYPES = ("SEGS",) + RETURN_NAMES = ("segs",) + OUTPUT_IS_LIST = (True,) + OUTPUT_TOOLTIPS = ( + "Automatic image regions as SEGS for detailing, masking, or tiled diffusion.", + ) + FUNCTION = "generate" + CATEGORY = "SimpleSyrup/Detection" + DESCRIPTION = "Creates automatic, unprompted image SEGS from a connected SAM model." + SEARCH_ALIASES = ["sam", "automatic", "segment", "segmentation", "segs"] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare inputs for automatic SAM-to-SEGS conversion.""" + + return { + "required": { + "image": ( + "IMAGE", + {"tooltip": "Image whose automatic regions become SEGS."}, + ), + "sam_model": ( + "SAM_MODEL", + {"tooltip": "SAM model used to find unprompted image regions."}, + ), + "segmentation_resolution": ( + "INT", + { + "default": 640, + "min": 64, + "max": 8192, + "step": 64, + "tooltip": ( + "Maximum long edge in pixels used for segmentation. " + "Lower values run faster and omit smaller details." + ), + }, + ), + "minimum_region_area": ( + "INT", + { + "default": 0, + "min": 0, + "max": 268435456, + "step": 1, + "tooltip": ( + "Discard masks smaller than this many pixels in the " + "original image." + ), + }, + ), + } + } + + def generate( + self, + image: object, + sam_model: object, + segmentation_resolution: int = 640, + minimum_region_area: int = 0, + ) -> tuple[list[object]]: + """Return one automatic SEGS payload for each image batch item.""" + + image_batch = validate_image_batch(image, "SEGS from SAM Output") + service = self.service_class() + phase_progress = type(self).progress_factory( + operation="segs_from_sam_output", + subject=_sam_model_subject(sam_model), + total_phases=int(image_batch.shape[0]) * 3 + 1, + ) + outputs: list[object] = [] + try: + for single_image in iter_single_images(image_batch): + outputs.append( + service.build( + image=single_image, + sam_model=sam_model, + segmentation_resolution=segmentation_resolution, + minimum_region_area=minimum_region_area, + phase_progress=phase_progress, + ) + ) + except Exception: + phase_progress.advance("failed") + raise + phase_progress.advance("completed") + return (outputs,) + + +def _sam_model_subject(sam_model: object) -> str: + """Return a concise model identity for Comfy progress diagnostics.""" + + model_id = getattr(sam_model, "model_id", None) + if isinstance(model_id, str) and model_id: + return model_id + return type(sam_model).__name__ diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index ad3da19..3bbfa89 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -39,6 +39,7 @@ def get_nodes() -> list[type[object]]: ResizeImageToTargetV3, SAMModelLoaderV3, SeedV3, + SEGSFromSAMOutputV3, SimpleLoadAnimaV3, SimpleVAEEncodeV3, UpscaleLatentFromImageV3, @@ -83,6 +84,7 @@ def get_nodes() -> list[type[object]]: PromptSEGSWithSAMV3, ResizeImageToTargetV3, SAMModelLoaderV3, + SEGSFromSAMOutputV3, ScaleFactorV3, SeedV3, SimpleLoadAnimaV3, diff --git a/simple_syrup/nodes_v3/legacy_node_wrappers.py b/simple_syrup/nodes_v3/legacy_node_wrappers.py index b99801e..1c29167 100644 --- a/simple_syrup/nodes_v3/legacy_node_wrappers.py +++ b/simple_syrup/nodes_v3/legacy_node_wrappers.py @@ -37,6 +37,7 @@ from ..nodes.prompt_segs_with_sam import PromptSEGSWithSAM from ..nodes.provenance_latent import SimpleVAEEncode, UpscaleLatentFromImage from ..nodes.sam_model_loader import SAMModelLoader from ..nodes.seed import Seed +from ..nodes.segs_from_sam_output import SEGSFromSAMOutput from ..nodes.simple_load_anima import SimpleLoadAnima from ..nodes.vitmatte_model_loader import ViTMatteModelLoader @@ -254,6 +255,14 @@ class SAMModelLoaderV3(LegacyNodeV3Adapter): DISPLAY_NAME = "SAM Model Loader" +class SEGSFromSAMOutputV3(LegacyNodeV3Adapter): + """Expose automatic SAM-to-SEGS generation through Comfy v3 only.""" + + LEGACY_NODE_CLASS = SEGSFromSAMOutput + NODE_ID = "SimpleSyrup.SEGSFromSAMOutput" + DISPLAY_NAME = "SEGS from SAM Output" + + class SeedV3(LegacyNodeV3Adapter): """Expose Seed through Comfy v3 only.""" @@ -505,6 +514,7 @@ __all__ = [ "PromptSEGSWithSAMV3", "ResizeImageToTargetV3", "SAMModelLoaderV3", + "SEGSFromSAMOutputV3", "SeedV3", "SimpleLoadAnimaV3", "SimpleVAEEncodeV3", diff --git a/simple_syrup/runtime/mixture_of_diffusers_sampling.py b/simple_syrup/runtime/mixture_of_diffusers_sampling.py index fda3501..249a9a4 100644 --- a/simple_syrup/runtime/mixture_of_diffusers_sampling.py +++ b/simple_syrup/runtime/mixture_of_diffusers_sampling.py @@ -31,6 +31,7 @@ from .tiled_sampling import ( ApplyModel, Latent, ModelFunctionWrapper, + SemanticTileWeightCache, make_tiled_model_args, new_spatial_weight_buffer, reject_unsupported_conditioning, @@ -63,6 +64,7 @@ def sample_mixture_of_diffusers( preview_context: DetailPreviewContext | None = None, differential_diffusion: bool = False, allow_full_context_masks: bool = False, + tiled_plan: TiledDiffusionPlan | None = None, ) -> Latent: """Sample a latent with a cloned model patched for Mixture of Diffusers.""" @@ -117,6 +119,7 @@ def sample_mixture_of_diffusers( overlap=latent_tile_overlap, tile_batch_size=latent_tile_batch_size, differential_diffusion=differential_diffusion, + tiled_plan=tiled_plan, ) batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None @@ -173,10 +176,11 @@ def clone_model_with_mixture_of_diffusers( overlap: int, tile_batch_size: int, differential_diffusion: bool = False, + tiled_plan: TiledDiffusionPlan | None = None, ) -> tuple[Any, TiledDiffusionPlan]: """Return a model clone patched with a pre-CFG Mixture wrapper.""" - plan = build_tiled_diffusion_plan( + plan = tiled_plan or build_tiled_diffusion_plan( latent_width=latent_width, latent_height=latent_height, tile_width=tile_width, @@ -184,6 +188,7 @@ def clone_model_with_mixture_of_diffusers( overlap=overlap, tile_batch_size=tile_batch_size, ) + _validate_supplied_plan(plan, latent_width, latent_height) cloned_model = model.clone() if differential_diffusion: install_differential_diffusion(cloned_model) @@ -213,6 +218,7 @@ class MixtureOfDiffusersModelWrapper: self._plan = plan self._existing_wrapper = existing_wrapper self._tile_weights_2d: torch.Tensor | None = None + self._semantic_tile_weights = SemanticTileWeightCache(plan.tiles) def __call__( self, @@ -245,7 +251,6 @@ class MixtureOfDiffusersModelWrapper: output_buffer = torch.zeros_like(x) weight_buffer = new_spatial_weight_buffer(x, self._plan) input_batch_size = int(x.shape[0]) - weights = self._weights_for(x) for batch in self._plan.batches: tiled_args = self._make_tiled_args( @@ -254,14 +259,22 @@ class MixtureOfDiffusersModelWrapper: input_batch_size=input_batch_size, ) tile_output = self._call_original(apply_model, tiled_args) + weights = self._weights_for(tile_output) + accumulation_weights = weights.to(dtype=weight_buffer.dtype) + semantic_weights = self._semantic_tile_weights.for_output(tile_output) for index, tile in enumerate(batch): tile_slice = spatial_tile_slicer(tile, x.ndim) start = index * input_batch_size end = start + input_batch_size - output_buffer[tile_slice] += tile_output[start:end] * weights.to( - dtype=tile_output.dtype + model_weight, accumulation_weight = ( + self._semantic_tile_weights.for_tile( + semantic_weights, + tile, + ) ) - weight_buffer[tile_slice] += weights.to(dtype=weight_buffer.dtype) + tile_weight = weights * model_weight + output_buffer[tile_slice] += tile_output[start:end] * tile_weight + weight_buffer[tile_slice] += accumulation_weights * accumulation_weight return output_buffer / weight_buffer.to(dtype=output_buffer.dtype) @@ -315,6 +328,21 @@ class MixtureOfDiffusersModelWrapper: ) +def _validate_supplied_plan( + plan: TiledDiffusionPlan, + latent_width: int, + latent_height: int, +) -> None: + """Reject a semantic tile plan that belongs to another latent shape.""" + + if (plan.latent_width, plan.latent_height) == (latent_width, latent_height): + return + raise ValueError( + "Mixture of Diffusers tiled plan dimensions must match the sampled latent " + "shape." + ) + + def _comfy_sample() -> ModuleType: """Import ComfyUI sample helpers lazily.""" diff --git a/simple_syrup/runtime/model_catalog.py b/simple_syrup/runtime/model_catalog.py index f8a8159..5c79ad0 100644 --- a/simple_syrup/runtime/model_catalog.py +++ b/simple_syrup/runtime/model_catalog.py @@ -190,6 +190,25 @@ SAM_ENTRIES: tuple[ModelEntry, ...] = ( ), ), ), + ModelEntry( + entry_id="fast_sam_s", + display_name="FastSAM-s (23MB)", + family=ModelFamily.SAM, + model_type="fast_sam", + source_repo="ultralytics/assets", + artifacts=( + ModelArtifact( + artifact_id="fast_sam_s_checkpoint", + filename="FastSAM-s.pt", + folder_name="sams", + source_url=( + "https://github.com/ultralytics/assets/releases/latest/download/" + "FastSAM-s.pt" + ), + description="FastSAM-s checkpoint", + ), + ), + ), ) GROUNDING_DINO_ENTRIES: tuple[ModelEntry, ...] = ( diff --git a/simple_syrup/runtime/multidiffusion_sampling.py b/simple_syrup/runtime/multidiffusion_sampling.py index c9a732a..5db98e7 100644 --- a/simple_syrup/runtime/multidiffusion_sampling.py +++ b/simple_syrup/runtime/multidiffusion_sampling.py @@ -30,6 +30,7 @@ from .tiled_sampling import ( ApplyModel, Latent, ModelFunctionWrapper, + SemanticTileWeightCache, make_tiled_model_args, new_spatial_weight_buffer, reject_unsupported_conditioning, @@ -63,6 +64,7 @@ def sample_multidiffusion( preview_context: DetailPreviewContext | None = None, differential_diffusion: bool = False, allow_full_context_masks: bool = False, + tiled_plan: TiledDiffusionPlan | None = None, ) -> Latent: """Sample a latent with a cloned model patched for MultiDiffusion.""" @@ -118,6 +120,7 @@ def sample_multidiffusion( overlap=latent_tile_overlap, tile_batch_size=latent_tile_batch_size, differential_diffusion=differential_diffusion, + tiled_plan=tiled_plan, ) batch_inds = latent_image["batch_index"] if "batch_index" in latent_image else None @@ -175,10 +178,11 @@ def clone_model_with_multidiffusion( overlap: int, tile_batch_size: int, differential_diffusion: bool = False, + tiled_plan: TiledDiffusionPlan | None = None, ) -> tuple[Any, TiledDiffusionPlan]: """Return a model clone patched with a pre-CFG MultiDiffusion wrapper.""" - plan = build_tiled_diffusion_plan( + plan = tiled_plan or build_tiled_diffusion_plan( latent_width=latent_width, latent_height=latent_height, tile_width=tile_width, @@ -186,6 +190,7 @@ def clone_model_with_multidiffusion( overlap=overlap, tile_batch_size=tile_batch_size, ) + _validate_supplied_plan(plan, latent_width, latent_height) cloned_model = model.clone() if differential_diffusion: install_differential_diffusion(cloned_model) @@ -214,6 +219,7 @@ class MultiDiffusionModelWrapper: self._plan = plan self._existing_wrapper = existing_wrapper + self._semantic_tile_weights = SemanticTileWeightCache(plan.tiles) def __call__( self, @@ -254,12 +260,19 @@ class MultiDiffusionModelWrapper: input_batch_size=input_batch_size, ) tile_output = self._call_original(apply_model, tiled_args) + tile_weights = self._semantic_tile_weights.for_output(tile_output) for index, tile in enumerate(batch): tile_slice = spatial_tile_slicer(tile, x.ndim) start = index * input_batch_size end = start + input_batch_size - output_buffer[tile_slice] += tile_output[start:end] - weight_buffer[tile_slice] += 1.0 + model_weight, accumulation_weight = ( + self._semantic_tile_weights.for_tile( + tile_weights, + tile, + ) + ) + output_buffer[tile_slice] += tile_output[start:end] * model_weight + weight_buffer[tile_slice] += accumulation_weight return output_buffer / weight_buffer.to(dtype=output_buffer.dtype) @@ -302,6 +315,20 @@ def _reject_unipc_sampler(sampler_name: str) -> None: raise ValueError("MultiDiffusion is not compatible with UniPC samplers.") +def _validate_supplied_plan( + plan: TiledDiffusionPlan, + latent_width: int, + latent_height: int, +) -> None: + """Reject a semantic tile plan that belongs to another latent shape.""" + + if (plan.latent_width, plan.latent_height) == (latent_width, latent_height): + return + raise ValueError( + "MultiDiffusion tiled plan dimensions must match the sampled latent shape." + ) + + def _sampling_callback( model: Any, steps: int, diff --git a/simple_syrup/runtime/sam_automatic_segmenter.py b/simple_syrup/runtime/sam_automatic_segmenter.py new file mode 100644 index 0000000..853cfb0 --- /dev/null +++ b/simple_syrup/runtime/sam_automatic_segmenter.py @@ -0,0 +1,253 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Run unprompted automatic segmentation with supported SAM model families.""" + +from __future__ import annotations + +import importlib +from dataclasses import dataclass +from typing import Any, Protocol, cast + +import numpy as np +import torch + +from .loaded_models import LoadedSAMModel, unwrap_sam_model +from .model_device_manager import TorchModelDeviceManager, external_model_inference +from .sam_loader import SAM_HQ_RUNTIME_PACKAGE + + +@dataclass(frozen=True) +class AutomaticSAMMask: + """Describe one unprompted mask emitted by a SAM-compatible runtime.""" + + mask: torch.Tensor + confidence: float + label: str | None = None + + +class SAMAutomaticSegmenter(Protocol): + """Generate unprompted masks from one SAM-compatible model and image.""" + + def segment_all( + self, + sam_model: object, + image: torch.Tensor, + execution_device: str = "auto", + ) -> tuple[AutomaticSAMMask, ...]: + """Return source-image masks with optional confidence and labels.""" + + +class SAMModelAutomaticSegmenter: + """Adapt SimpleSyrup SAM models to their unprompted segmentation APIs.""" + + def segment_all( + self, + sam_model: object, + image: torch.Tensor, + execution_device: str = "auto", + ) -> tuple[AutomaticSAMMask, ...]: + """Return automatic masks for one single-image BHWC tensor.""" + + image_array = _tensor_to_rgb_array(image) + model = unwrap_sam_model(sam_model) + if _is_fast_sam(sam_model, model): + return self._segment_fast_sam( + sam_model=sam_model, + model=model, + image_array=image_array, + execution_device=execution_device, + ) + return self._segment_segment_anything( + sam_model=sam_model, + model=model, + image_array=image_array, + execution_device=execution_device, + ) + + def _segment_fast_sam( + self, + *, + sam_model: object, + model: object, + image_array: np.ndarray[Any, Any], + execution_device: str, + ) -> tuple[AutomaticSAMMask, ...]: + """Run FastSAM's unprompted everything-mask path.""" + + if ( + isinstance(sam_model, LoadedSAMModel) + and sam_model.managed_model is not None + ): + with TorchModelDeviceManager().inference( + sam_model.managed_model, + execution_device, + ) as loaded: + return _fast_sam_masks(loaded.model, image_array, loaded.device) + with external_model_inference(model, execution_device) as loaded: + return _fast_sam_masks(loaded.model, image_array, loaded.device) + + def _segment_segment_anything( + self, + *, + sam_model: object, + model: object, + image_array: np.ndarray[Any, Any], + execution_device: str, + ) -> tuple[AutomaticSAMMask, ...]: + """Run Segment Anything automatic-mask generation under device management.""" + + if ( + isinstance(sam_model, LoadedSAMModel) + and sam_model.managed_model is not None + ): + with TorchModelDeviceManager().inference( + sam_model.managed_model, + execution_device, + ) as loaded: + return _segment_anything_masks( + loaded.model, + image_array, + use_hq_generator=_uses_sam_hq_generator(sam_model), + ) + with external_model_inference(model, execution_device) as loaded: + return _segment_anything_masks( + loaded.model, + image_array, + use_hq_generator=_uses_sam_hq_generator(model), + ) + + +def _tensor_to_rgb_array(image: torch.Tensor) -> np.ndarray[Any, Any]: + """Convert one BHWC ComfyUI image into a uint8 RGB array.""" + + if image.ndim != 4 or int(image.shape[0]) != 1: + raise ValueError( + "SAM automatic segmentation requires one BHWC image at a time." + ) + sample = image[0].detach().cpu().float().clamp(0.0, 1.0).numpy() + channels = int(sample.shape[-1]) + if channels == 1: + sample = np.repeat(sample, 3, axis=-1) + elif channels >= 3: + sample = sample[..., :3] + else: + raise ValueError( + "SAM automatic segmentation requires at least one image channel." + ) + return (sample * 255.0).round().astype(np.uint8) + + +def _is_fast_sam(container: object, model: object) -> bool: + """Return whether a loaded or external model uses FastSAM's API.""" + + if isinstance(container, LoadedSAMModel): + return container.model_id.startswith("fast_sam") + return type(model).__name__ == "FastSAM" + + +def _fast_sam_masks( + model: object, + image_array: np.ndarray[Any, Any], + device: torch.device, +) -> tuple[AutomaticSAMMask, ...]: + """Extract FastSAM masks and scores from one Ultralytics result.""" + + predict = getattr(model, "predict", None) + if not callable(predict): + raise TypeError("FastSAM model does not expose the required predict method.") + results = predict( + image_array, + imgsz=max(image_array.shape[:2]), + retina_masks=True, + verbose=False, + device=str(device), + ) + if not isinstance(results, list | tuple) or not results: + return () + result = results[0] + masks_container = getattr(result, "masks", None) + mask_data = getattr(masks_container, "data", None) + if not isinstance(mask_data, torch.Tensor): + return () + boxes = getattr(result, "boxes", None) + confidences = getattr(boxes, "conf", None) + entries: list[AutomaticSAMMask] = [] + for index, mask in enumerate(mask_data.detach().cpu()): + confidence = 1.0 + if isinstance(confidences, torch.Tensor) and index < int(confidences.numel()): + confidence = float(confidences[index].detach().cpu().item()) + entries.append( + AutomaticSAMMask( + mask=mask.float().clamp(0.0, 1.0), + confidence=_clamp_confidence(confidence), + ) + ) + return tuple(entries) + + +def _segment_anything_masks( + model: object, + image_array: np.ndarray[Any, Any], + *, + use_hq_generator: bool, +) -> tuple[AutomaticSAMMask, ...]: + """Extract binary masks and predicted quality from automatic generators.""" + + generator_class = _automatic_generator_class(use_hq_generator) + generated = generator_class(model).generate(image_array) + if not isinstance(generated, list): + raise TypeError("SAM automatic mask generator returned an invalid result.") + masks: list[AutomaticSAMMask] = [] + for entry in generated: + if not isinstance(entry, dict) or "segmentation" not in entry: + raise ValueError("SAM automatic mask generator returned an invalid mask.") + raw_confidence = entry.get("predicted_iou", entry.get("stability_score", 1.0)) + confidence = ( + float(raw_confidence) if isinstance(raw_confidence, int | float) else 1.0 + ) + masks.append( + AutomaticSAMMask( + mask=torch.as_tensor(entry["segmentation"], dtype=torch.float32) + .detach() + .cpu() + .clamp(0.0, 1.0), + confidence=_clamp_confidence(confidence), + ) + ) + return tuple(masks) + + +def _automatic_generator_class(use_hq_generator: bool) -> type[Any]: + """Return the automatic generator matching the loaded SAM family.""" + + try: + if use_hq_generator: + automatic = importlib.import_module(f"{SAM_HQ_RUNTIME_PACKAGE}.automatic") + return cast(type[Any], automatic.SamAutomaticMaskGeneratorHQ) + segment_anything = importlib.import_module("segment_anything") + return cast(type[Any], segment_anything.SamAutomaticMaskGenerator) + except ImportError as error: + raise RuntimeError( + "SAM automatic segmentation requires the matching Segment Anything " + "runtime. " + f"Import failed: {error}." + ) from error + + +def _uses_sam_hq_generator(model: object) -> bool: + """Return whether a model container or raw object requires SAM-HQ generation.""" + + if isinstance(model, LoadedSAMModel): + return model.model_id.startswith("sam_hq") or model.model_id == "mobile_sam" + model_name = getattr(model, "model_name", "") + return isinstance(model_name, str) and ( + model_name.startswith("sam_hq") or model_name == "mobile_sam" + ) + + +def _clamp_confidence(value: float) -> float: + """Return one confidence value constrained to the public SEGS range.""" + + return max(0.0, min(1.0, value)) diff --git a/simple_syrup/runtime/sam_loader.py b/simple_syrup/runtime/sam_loader.py index 06b78ea..5a515d5 100644 --- a/simple_syrup/runtime/sam_loader.py +++ b/simple_syrup/runtime/sam_loader.py @@ -11,7 +11,7 @@ from collections.abc import MutableMapping from dataclasses import dataclass from pathlib import Path from types import ModuleType -from typing import Any +from typing import Any, cast from ..shared.logging import get_logger from .loaded_models import LoadedSAMModel @@ -118,7 +118,7 @@ class SAMLoaderService: """Load and wrap a SAM model after artifact resolution and cache lookup.""" phase_progress.advance("loading_checkpoint") - model = self._load_segment_anything_model(entry, checkpoint_path) + model = self._load_model(entry, checkpoint_path) phase_progress.advance("registering_device_management") managed_model = self._device_manager.manage( model, @@ -184,12 +184,15 @@ class SAMLoaderService: paths.append(result.path) return paths - def _load_segment_anything_model( + def _load_model( self, entry: ModelEntry, checkpoint_path: Path, ) -> object: - """Load a SAM model from the segment-anything registry.""" + """Load one SAM-compatible model from its owning runtime.""" + + if entry.model_type == "fast_sam": + return self._load_fast_sam_model(checkpoint_path) try: importlib.invalidate_caches() @@ -206,6 +209,19 @@ class SAMLoaderService: model.model_name = checkpoint_path.name return model + def _load_fast_sam_model(self, checkpoint_path: Path) -> object: + """Load FastSAM without delegating checkpoint download to Ultralytics.""" + + try: + ultralytics = importlib.import_module("ultralytics") + fast_sam_class = cast(Any, ultralytics).FastSAM + except (ImportError, AttributeError) as error: + raise RuntimeError( + "FastSAM support requires the installed ultralytics package. " + f"Import failed: {error}." + ) from error + return fast_sam_class(str(checkpoint_path)) + def _registry_module_name(model_type: str) -> str: """Return the registry module that owns one SAM-compatible model type.""" @@ -230,4 +246,6 @@ def _registry_import_error_message(model_type: str, error: ImportError) -> str: "runtime and its dependencies. Reinstall SimpleSyrup or restore " f"{SAM_HQ_RUNTIME_PACKAGE}. Import failed: {error}." ) + if model_type == "fast_sam": + return "FastSAM support requires the installed ultralytics package." return f"segment-anything is required to load SAM models. Import failed: {error}." diff --git a/simple_syrup/runtime/tiled_sampling.py b/simple_syrup/runtime/tiled_sampling.py index 1c1503e..fde1bbf 100644 --- a/simple_syrup/runtime/tiled_sampling.py +++ b/simple_syrup/runtime/tiled_sampling.py @@ -11,6 +11,7 @@ from __future__ import annotations from collections.abc import Callable, Sequence +from dataclasses import dataclass from typing import Any, TypeAlias import torch @@ -24,6 +25,72 @@ ModelFunctionWrapper: TypeAlias = Callable[[ApplyModel, dict[str, Any]], torch.T UNSUPPORTED_CONDITIONING_KEYS = frozenset({"area", "control", "gligen"}) +@dataclass(frozen=True) +class CachedTileWeights: + """Store model-output and float32 accumulation weights for one tensor layout.""" + + model: tuple[torch.Tensor, ...] + accumulation: tuple[torch.Tensor, ...] + + +class SemanticTileWeightCache: + """Keep semantic tile weights resident on the active sampling device.""" + + def __init__(self, tiles: Sequence[LatentTile]) -> None: + """Create an empty cache associated with one immutable tile plan.""" + + self._tiles = tuple(tiles) + self._tile_indexes = {id(tile): index for index, tile in enumerate(self._tiles)} + self._cache_key: tuple[torch.device, torch.dtype, int] | None = None + self._weights: CachedTileWeights | None = None + + def for_output(self, output: torch.Tensor) -> CachedTileWeights: + """Return tile weights shaped and typed for one model output tensor.""" + + cache_key = (output.device, output.dtype, output.ndim) + if self._cache_key == cache_key and self._weights is not None: + return self._weights + output_weights: list[torch.Tensor] = [] + accumulation_weights: list[torch.Tensor] = [] + for tile in self._tiles: + shape = (1,) * (output.ndim - 2) + (tile.height, tile.width) + if tile.weight_mask is None: + accumulation_weight = torch.ones( + shape, + device=output.device, + dtype=torch.float32, + ) + else: + if tuple(tile.weight_mask.shape) != (tile.height, tile.width): + raise ValueError( + "Semantic tile weight must match its tile dimensions." + ) + accumulation_weight = tile.weight_mask.to( + device=output.device, + dtype=torch.float32, + ).reshape(shape) + accumulation_weights.append(accumulation_weight) + output_weights.append(accumulation_weight.to(dtype=output.dtype)) + self._cache_key = cache_key + self._weights = CachedTileWeights( + model=tuple(output_weights), + accumulation=tuple(accumulation_weights), + ) + return self._weights + + def for_tile( + self, + weights: CachedTileWeights, + tile: LatentTile, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Return cached model and accumulation weights for one planned tile.""" + + index = self._tile_indexes.get(id(tile)) + if index is None: + raise ValueError("Semantic tile weight cache received an unknown tile.") + return weights.model[index], weights.accumulation[index] + + def validate_sampling_controls( *, steps: int, diff --git a/simple_syrup/services/segs_from_sam_output_service.py b/simple_syrup/services/segs_from_sam_output_service.py new file mode 100644 index 0000000..403f8d4 --- /dev/null +++ b/simple_syrup/services/segs_from_sam_output_service.py @@ -0,0 +1,372 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Convert unprompted SAM masks into image-associated SEGS.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import ceil, floor +from time import perf_counter + +import torch +import torch.nn.functional as functional + +from ..domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from ..masking.segs_mask_ops import validate_single_image +from ..runtime.progress import NullPhaseProgressReporter, PhaseProgressReporter +from ..runtime.sam_automatic_segmenter import ( + AutomaticSAMMask, + SAMAutomaticSegmenter, + SAMModelAutomaticSegmenter, +) +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + + +@dataclass(frozen=True) +class SAMAutoSegsSettings: + """Validated controls for converting automatic SAM masks to SEGS.""" + + segmentation_resolution: int + minimum_region_area: int + + +@dataclass(frozen=True) +class _GuideMaskCandidate: + """Keep a retained SAM mask in compact guide-image coordinates.""" + + mask: torch.Tensor + bbox: BoundingBox + confidence: float + label: str | None + + +class SEGSFromSAMOutputService: + """Build reusable SEGS from a SAM model's unprompted masks.""" + + def __init__(self, segmenter: SAMAutomaticSegmenter | None = None) -> None: + """Create the service with an injectable automatic segmentation runtime.""" + + self._segmenter = segmenter or SAMModelAutomaticSegmenter() + + def build( + self, + *, + image: object, + sam_model: object, + segmentation_resolution: int, + minimum_region_area: int, + phase_progress: PhaseProgressReporter | None = None, + ) -> NativeSegs: + """Return source-sized SEGS from unprompted SAM masks.""" + + operation_started_at = perf_counter() + reporter = phase_progress or NullPhaseProgressReporter() + source_image = validate_single_image(image, "SEGS from SAM Output") + settings = _validate_settings( + segmentation_resolution=segmentation_resolution, + minimum_region_area=minimum_region_area, + ) + image_height = int(source_image.shape[1]) + image_width = int(source_image.shape[2]) + reporter.advance("preparing_segmentation_image") + guide_image = _resize_for_segmentation( + source_image, + maximum_long_edge=settings.segmentation_resolution, + ) + reporter.advance("generating_automatic_masks") + generated = self._segmenter.segment_all(sam_model, guide_image) + masks_generated_at = perf_counter() + reporter.advance("building_segs") + candidates = _normalize_masks( + generated, + source_height=image_height, + source_width=image_width, + minimum_region_area=settings.minimum_region_area, + ) + segments = tuple( + _build_segment( + image=source_image, + candidate=candidate, + source_height=image_height, + source_width=image_width, + confidence=candidate.confidence, + label=candidate.label or f"segment_{index:03d}", + ) + for index, candidate in enumerate(candidates, start=1) + ) + LOGGER.info( + "Built SEGS from SAM output", + extra={ + "operation": "segs_from_sam_output", + "segmentation_resolution": settings.segmentation_resolution, + "minimum_region_area": settings.minimum_region_area, + "source_height": image_height, + "source_width": image_width, + "generated_mask_count": len(generated), + "segment_count": len(segments), + "mask_generation_ms": round( + (masks_generated_at - operation_started_at) * 1000.0, + 2, + ), + "segs_construction_ms": round( + (perf_counter() - masks_generated_at) * 1000.0, + 2, + ), + }, + ) + return (image_height, image_width), segments + + +def _validate_settings( + *, + segmentation_resolution: int, + minimum_region_area: int, +) -> SAMAutoSegsSettings: + """Validate public automatic-SEGS configuration before model execution.""" + + if segmentation_resolution < 64 or segmentation_resolution % 64 != 0: + raise ValueError( + "segmentation_resolution must be at least 64 and divisible by 64." + ) + if minimum_region_area < 0: + raise ValueError("minimum_region_area must be greater than or equal to 0.") + return SAMAutoSegsSettings( + segmentation_resolution=segmentation_resolution, + minimum_region_area=minimum_region_area, + ) + + +def _resize_for_segmentation( + image: torch.Tensor, + *, + maximum_long_edge: int, +) -> torch.Tensor: + """Downscale an image to the requested guide resolution without upscaling it.""" + + source_height = int(image.shape[1]) + source_width = int(image.shape[2]) + source_long_edge = max(source_height, source_width) + if source_long_edge <= maximum_long_edge: + return image + scale = maximum_long_edge / source_long_edge + guide_height = max(1, round(source_height * scale)) + guide_width = max(1, round(source_width * scale)) + resized = functional.interpolate( + image.movedim(-1, 1), + size=(guide_height, guide_width), + mode="bilinear", + align_corners=False, + ) + return resized.movedim(1, -1).clamp(0.0, 1.0) + + +def _normalize_masks( + masks: tuple[AutomaticSAMMask, ...], + *, + source_height: int, + source_width: int, + minimum_region_area: int, +) -> tuple[_GuideMaskCandidate, ...]: + """Filter and deduplicate masks while they remain at guide resolution.""" + + normalized: list[_GuideMaskCandidate] = [] + for candidate in masks: + binary = _binary_guide_mask(candidate.mask) + bbox = _bbox_from_mask(binary) + if bbox is None: + continue + if ( + _projected_source_area( + active_pixels=int(binary.sum().item()), + guide_height=int(binary.shape[0]), + guide_width=int(binary.shape[1]), + source_height=source_height, + source_width=source_width, + ) + < minimum_region_area + ): + continue + normalized_candidate = _GuideMaskCandidate( + mask=binary, + bbox=bbox, + confidence=candidate.confidence, + label=candidate.label, + ) + duplicate_index = _duplicate_mask_index(normalized, normalized_candidate) + if duplicate_index is None: + normalized.append(normalized_candidate) + elif normalized_candidate.confidence > normalized[duplicate_index].confidence: + normalized[duplicate_index] = normalized_candidate + return tuple(normalized) + + +def _binary_guide_mask(mask: torch.Tensor) -> torch.Tensor: + """Return one validated binary guide-space mask on the CPU.""" + + working = mask.detach().cpu().float() + if working.ndim != 2: + raise ValueError("SAM automatic segmentation returned a mask that is not HW.") + if int(working.shape[0]) == 0 or int(working.shape[1]) == 0: + raise ValueError("SAM automatic segmentation returned an empty mask.") + return working >= 0.5 + + +def _projected_source_area( + *, + active_pixels: int, + guide_height: int, + guide_width: int, + source_height: int, + source_width: int, +) -> float: + """Estimate source-pixel area directly from one guide-space mask.""" + + return ( + active_pixels * source_height * source_width / float(guide_height * guide_width) + ) + + +def _duplicate_mask_index( + candidates: list[_GuideMaskCandidate], + candidate: _GuideMaskCandidate, +) -> int | None: + """Return a near-identical guide mask index without broad pairwise scans.""" + + for index, existing in enumerate(candidates): + if ( + tuple(existing.mask.shape) != tuple(candidate.mask.shape) + or _bbox_iou(existing.bbox, candidate.bbox) < 0.98 + ): + continue + union = torch.logical_or(existing.mask, candidate.mask).sum() + if int(union.item()) == 0: + continue + intersection = torch.logical_and(existing.mask, candidate.mask).sum() + if float(intersection.item()) / float(union.item()) >= 0.98: + return index + return None + + +def _build_segment( + *, + image: torch.Tensor, + candidate: _GuideMaskCandidate, + source_height: int, + source_width: int, + confidence: float, + label: str, +) -> Segment: + """Build one crop-local SEG without ever expanding a full source mask.""" + + crop_region = _source_crop_region( + candidate.bbox, + guide_height=int(candidate.mask.shape[0]), + guide_width=int(candidate.mask.shape[1]), + source_height=source_height, + source_width=source_width, + ) + guide_crop = candidate.mask[ + candidate.bbox.top : candidate.bbox.bottom, + candidate.bbox.left : candidate.bbox.right, + ] + local_mask = _resize_local_mask( + guide_crop, + height=crop_region.height, + width=crop_region.width, + ) + return Segment( + cropped_image=image[ + :, + crop_region.top : crop_region.bottom, + crop_region.left : crop_region.right, + :, + ] + .detach() + .clone(), + cropped_mask=local_mask.detach().clone(), + confidence=max(0.0, min(1.0, float(confidence))), + crop_region=crop_region, + bbox=BoundingBox( + crop_region.left, + crop_region.top, + crop_region.right, + crop_region.bottom, + ), + label=label, + ) + + +def _bbox_from_mask(mask: torch.Tensor) -> BoundingBox | None: + """Return the tight bounding box for one active guide-space mask.""" + + y_coords, x_coords = torch.where(mask) + if y_coords.numel() == 0: + return None + return BoundingBox( + left=int(x_coords.min().item()), + top=int(y_coords.min().item()), + right=int(x_coords.max().item()) + 1, + bottom=int(y_coords.max().item()) + 1, + ) + + +def _bbox_iou(first: BoundingBox, second: BoundingBox) -> float: + """Return the intersection-over-union of two rectangular bounds.""" + + overlap_width = max( + 0, min(first.right, second.right) - max(first.left, second.left) + ) + overlap_height = max( + 0, min(first.bottom, second.bottom) - max(first.top, second.top) + ) + intersection = overlap_width * overlap_height + union = first.width * first.height + second.width * second.height - intersection + return 0.0 if union == 0 else intersection / float(union) + + +def _source_crop_region( + guide_bbox: BoundingBox, + *, + guide_height: int, + guide_width: int, + source_height: int, + source_width: int, +) -> CropRegion: + """Map a guide-space bounding box to a conservative source-image crop.""" + + left = _map_lower_bound(guide_bbox.left, guide_width, source_width) + top = _map_lower_bound(guide_bbox.top, guide_height, source_height) + right = _map_upper_bound(guide_bbox.right, guide_width, source_width) + bottom = _map_upper_bound(guide_bbox.bottom, guide_height, source_height) + return CropRegion(left, top, right, bottom) + + +def _map_lower_bound(value: int, guide_limit: int, source_limit: int) -> int: + """Map a guide coordinate down while keeping it within source bounds.""" + + return min(source_limit - 1, max(0, floor(value * source_limit / guide_limit))) + + +def _map_upper_bound(value: int, guide_limit: int, source_limit: int) -> int: + """Map a guide coordinate up while keeping a non-empty source extent.""" + + return max(1, min(source_limit, ceil(value * source_limit / guide_limit))) + + +def _resize_local_mask(mask: torch.Tensor, *, height: int, width: int) -> torch.Tensor: + """Scale only a retained mask's tight crop into source pixel coordinates.""" + + return ( + functional.interpolate( + mask.unsqueeze(0).unsqueeze(0).float(), + size=(height, width), + mode="nearest", + ) + .squeeze(0) + .squeeze(0) + .float() + ) diff --git a/simple_syrup/services/tiled_diffusion_sampling_service.py b/simple_syrup/services/tiled_diffusion_sampling_service.py index fc3b177..a0fe716 100644 --- a/simple_syrup/services/tiled_diffusion_sampling_service.py +++ b/simple_syrup/services/tiled_diffusion_sampling_service.py @@ -11,7 +11,9 @@ from typing import Any import torch from ..domain.conditioning_batch import ConditioningBatch, select_conditioning -from ..domain.tiled_diffusion import validate_tiled_diffusion_mode +from ..domain.segs import coerce_segs_group +from ..domain.segs_tiled_diffusion import build_segs_guided_tiled_diffusion_plan +from ..domain.tiled_diffusion import TiledDiffusionPlan, validate_tiled_diffusion_mode from ..runtime import mixture_of_diffusers_sampling, multidiffusion_sampling from ..runtime.detail_previews import DetailPreviewContext @@ -42,10 +44,33 @@ class TiledDiffusionSamplingService: preview_context: DetailPreviewContext | None = None, differential_diffusion: bool = False, allow_full_context_masks: bool = False, + segs: object | None = None, ) -> Latent: """Sample a latent with the selected tiled diffusion method.""" validate_tiled_diffusion_mode(diffusion_mode) + if segs is not None: + return self._sample_segs_guided( + diffusion_mode=diffusion_mode, + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=preview_context, + differential_diffusion=differential_diffusion, + allow_full_context_masks=allow_full_context_masks, + segs=segs, + ) if self._uses_conditioning_batch(positive, negative): return self._sample_conditioning_batch( diffusion_mode=diffusion_mode, @@ -67,6 +92,52 @@ class TiledDiffusionSamplingService: differential_diffusion=differential_diffusion, allow_full_context_masks=allow_full_context_masks, ) + return self._sample_single( + diffusion_mode=diffusion_mode, + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=preview_context, + differential_diffusion=differential_diffusion, + allow_full_context_masks=allow_full_context_masks, + ) + + def _sample_single( + self, + *, + diffusion_mode: str, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None, + differential_diffusion: bool, + allow_full_context_masks: bool, + tiled_plan: TiledDiffusionPlan | None = None, + ) -> Latent: + """Route one single-latent tiled sample to its selected runtime.""" + if diffusion_mode == "multidiffusion": return multidiffusion_sampling.sample_multidiffusion( model=model, @@ -86,6 +157,7 @@ class TiledDiffusionSamplingService: preview_context=preview_context, differential_diffusion=differential_diffusion, allow_full_context_masks=allow_full_context_masks, + tiled_plan=tiled_plan, ) return mixture_of_diffusers_sampling.sample_mixture_of_diffusers( model=model, @@ -105,8 +177,100 @@ class TiledDiffusionSamplingService: preview_context=preview_context, differential_diffusion=differential_diffusion, allow_full_context_masks=allow_full_context_masks, + tiled_plan=tiled_plan, ) + def _sample_segs_guided( + self, + *, + diffusion_mode: str, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + latent_tile_width: int, + latent_tile_height: int, + latent_tile_overlap: int, + latent_tile_batch_size: int, + preview_context: DetailPreviewContext | None, + differential_diffusion: bool, + allow_full_context_masks: bool, + segs: object, + ) -> Latent: + """Sample every latent batch item using its connected SEGS guide.""" + + segs_group = coerce_segs_group(segs) + latent_samples = latent_image.get("samples") + if not isinstance(latent_samples, torch.Tensor): + raise TypeError("Tiled diffusion latent samples must be a torch.Tensor.") + batch_size = int(latent_samples.shape[0]) + if len(segs_group) not in (1, batch_size): + raise ValueError( + "SEGS-guided tiled diffusion requires one SEGS payload or one per " + f"latent batch item; received {len(segs_group)} SEGS payloads for " + f"batch size {batch_size}." + ) + + outputs: list[torch.Tensor] = [] + for index in range(batch_size): + item_latent = self._single_item_latent(latent_image, index) + samples = item_latent["samples"] + if not isinstance(samples, torch.Tensor): + raise TypeError( + "Tiled diffusion latent samples must be a torch.Tensor." + ) + segs_for_item = segs_group[0 if len(segs_group) == 1 else index] + plan = build_segs_guided_tiled_diffusion_plan( + segs=segs_for_item, + latent_width=int(samples.shape[-1]), + latent_height=int(samples.shape[-2]), + tile_width=latent_tile_width, + tile_height=latent_tile_height, + overlap=latent_tile_overlap, + tile_batch_size=latent_tile_batch_size, + ) + output = self._sample_single( + diffusion_mode=diffusion_mode, + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=select_conditioning(positive, index) + if isinstance(positive, ConditioningBatch) + else positive, + negative=select_conditioning(negative, index) + if isinstance(negative, ConditioningBatch) + else negative, + latent_image=item_latent, + denoise=denoise, + latent_tile_width=latent_tile_width, + latent_tile_height=latent_tile_height, + latent_tile_overlap=latent_tile_overlap, + latent_tile_batch_size=latent_tile_batch_size, + preview_context=preview_context, + differential_diffusion=differential_diffusion, + allow_full_context_masks=allow_full_context_masks, + tiled_plan=plan, + ) + output_samples = output["samples"] + if not isinstance(output_samples, torch.Tensor): + raise TypeError( + "Tiled diffusion output samples must be a torch.Tensor." + ) + outputs.append(output_samples) + result = latent_image.copy() + result.pop("downscale_ratio_spacial", None) + result["samples"] = torch.cat(outputs, dim=0) + return result + def _sample_conditioning_batch( self, *, diff --git a/tests/test_ksampler_tiled_diffusion_node.py b/tests/test_ksampler_tiled_diffusion_node.py index bd771b5..46d62c9 100644 --- a/tests/test_ksampler_tiled_diffusion_node.py +++ b/tests/test_ksampler_tiled_diffusion_node.py @@ -27,6 +27,7 @@ def test_input_types_match_tiled_diffusion_contract( lambda: ("normal",), ) required = KSamplerTiledDiffusion.INPUT_TYPES()["required"] + optional = KSamplerTiledDiffusion.INPUT_TYPES()["optional"] assert tuple(required) == ( "model", @@ -58,6 +59,7 @@ def test_input_types_match_tiled_diffusion_contract( assert required["latent_tile_height"][1]["max"] == 512 assert required["latent_tile_overlap"][1]["default"] == 16 assert required["latent_tile_batch_size"][1]["default"] == 4 + assert optional["segs"][0] == "SEGS" def test_node_metadata_matches_contract() -> None: @@ -118,6 +120,7 @@ def test_sample_delegates_to_shared_service( assert call["latent_tile_overlap"] == 24 assert call["latent_tile_batch_size"] == 3 assert call["preview_context"] is None + assert call["segs"] is None def test_invalid_diffusion_mode_fails_before_runtime_sampling() -> None: @@ -171,6 +174,7 @@ class _FakeTiledDiffusionSamplingService: latent_tile_overlap: int, latent_tile_batch_size: int, preview_context: Any | None = None, + segs: object | None = None, ) -> dict[str, Any]: """Record sampling arguments and return a fixed latent.""" @@ -192,6 +196,7 @@ class _FakeTiledDiffusionSamplingService: "latent_tile_overlap": latent_tile_overlap, "latent_tile_batch_size": latent_tile_batch_size, "preview_context": preview_context, + "segs": segs, } ) return self.output diff --git a/tests/test_registration.py b/tests/test_registration.py index bbd36c8..a127c4f 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -44,6 +44,7 @@ BASE_NODE_IDS = [ "SimpleSyrup.PromptSEGSWithSAM", "SimpleSyrup.ResizeImageToTarget", "SimpleSyrup.SAMModelLoader", + "SimpleSyrup.SEGSFromSAMOutput", "SimpleSyrup.ScaleFactor", "SimpleSyrup.Seed", "SimpleSyrup.SimpleLoadAnima", diff --git a/tests/test_sam_automatic_segmenter.py b/tests/test_sam_automatic_segmenter.py new file mode 100644 index 0000000..94c3599 --- /dev/null +++ b/tests/test_sam_automatic_segmenter.py @@ -0,0 +1,131 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for unprompted SAM-family automatic segmentation runtime adapters.""" + +from __future__ import annotations + +import sys +from types import ModuleType + +import pytest +import torch + +from simple_syrup.runtime.sam_automatic_segmenter import SAMModelAutomaticSegmenter + + +def test_fast_sam_adapter_extracts_everything_masks_and_confidences() -> None: + """FastSAM returns its direct mask results without text or CLIP prompting.""" + + class _Masks: + """Expose Ultralytics-style mask data.""" + + data = torch.tensor( + [ + [[1.0, 0.0], [0.0, 1.0]], + [[0.0, 1.0], [1.0, 0.0]], + ] + ) + + class _Boxes: + """Expose Ultralytics-style detection confidences.""" + + conf = torch.tensor([0.75, 0.5]) + + class _Result: + """Expose one Ultralytics segmentation result.""" + + masks = _Masks() + boxes = _Boxes() + + class FastSAM: + """Minimal FastSAM-like external model.""" + + def __init__(self) -> None: + """Create an empty prediction call recorder.""" + + self.calls: list[dict[str, object]] = [] + + def to(self, device: torch.device) -> None: + """Accept temporary CPU inference movement.""" + + del device + + def eval(self) -> None: + """Accept evaluation mode.""" + + def predict(self, image: object, **kwargs: object) -> list[_Result]: + """Record direct everything-mode prediction options.""" + + self.calls.append({"image": image, **kwargs}) + return [_Result()] + + model = FastSAM() + + masks = SAMModelAutomaticSegmenter().segment_all( + model, + torch.zeros((1, 2, 2, 3)), + execution_device="cpu", + ) + + assert [mask.confidence for mask in masks] == [0.75, 0.5] + assert torch.equal(masks[0].mask, _Masks.data[0]) + assert model.calls[0]["retina_masks"] is True + assert model.calls[0]["verbose"] is False + assert model.calls[0]["device"] == "cpu" + + +def test_segment_anything_adapter_preserves_predicted_iou( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Standard SAM automatic generation maps quality metadata to SEGS confidence.""" + + class _Generator: + """Return a fixed native automatic-mask response.""" + + def __init__(self, model: object) -> None: + """Accept the raw SAM model.""" + + del model + + def generate(self, image: object) -> list[dict[str, object]]: + """Return one native SAM automatic mask.""" + + del image + return [ + { + "segmentation": torch.tensor([[True, False], [False, True]]), + "predicted_iou": 0.92, + } + ] + + segment_anything = ModuleType("segment_anything") + segment_anything.SamAutomaticMaskGenerator = _Generator # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "segment_anything", segment_anything) + + class _SAM: + """Minimal raw SAM model.""" + + model_name = "sam_vit_b.pth" + + def to(self, device: torch.device) -> None: + """Accept temporary CPU inference movement.""" + + del device + + def eval(self) -> None: + """Accept evaluation mode.""" + + masks = SAMModelAutomaticSegmenter().segment_all( + _SAM(), + torch.zeros((1, 2, 2, 3)), + execution_device="cpu", + ) + + assert len(masks) == 1 + assert masks[0].confidence == 0.92 + assert torch.equal( + masks[0].mask, + torch.tensor([[1.0, 0.0], [0.0, 1.0]]), + ) diff --git a/tests/test_sam_loader.py b/tests/test_sam_loader.py index c1bf50a..3343dd7 100644 --- a/tests/test_sam_loader.py +++ b/tests/test_sam_loader.py @@ -212,6 +212,45 @@ def test_sam_loader_loads_sam_hq_from_owned_runtime( assert state.checkpoints == [str(tmp_path / "sams" / "sam_hq_vit_b.pth")] +def test_sam_loader_downloads_and_loads_fast_sam_s( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """FastSAM-s uses the existing SAM download, cache, and model wrapper path.""" + + downloader = RecordingDownloader() + constructed: list[str] = [] + + class FakeFastSAM: + """Minimal FastSAM-compatible model fake.""" + + def __init__(self, checkpoint: str) -> None: + """Record the checkpoint passed by the shared SAM loader.""" + + constructed.append(checkpoint) + + def to(self, device: object) -> None: + """Accept device management.""" + + def eval(self) -> None: + """Accept evaluation mode.""" + + ultralytics = ModuleType("ultralytics") + ultralytics.FastSAM = FakeFastSAM # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "ultralytics", ultralytics) + + loaded = SAMLoaderService( + downloader=downloader, # type: ignore[arg-type] + folder_paths_module=FakeFolderPaths(tmp_path), + ).load_model("FastSAM-s (23MB)", auto_download=True) + + expected = str(tmp_path / "sams" / "FastSAM-s.pt") + assert downloader.requests[0].destination_path == Path(expected) + assert constructed == [expected] + assert loaded.model_id == "fast_sam_s" + assert loaded.managed_model is not None + + def test_sam_loader_errors_when_missing_and_download_disabled(tmp_path: Path) -> None: """SAM loader fails clearly when downloads are disabled.""" diff --git a/tests/test_sam_model_loader_node.py b/tests/test_sam_model_loader_node.py index c04334d..6ac3030 100644 --- a/tests/test_sam_model_loader_node.py +++ b/tests/test_sam_model_loader_node.py @@ -31,6 +31,7 @@ def test_sam_model_loader_declares_expected_inputs() -> None: assert set(required) == {"sam_model"} assert "sam_vit_b (375MB)" in required["sam_model"][0] + assert "FastSAM-s (23MB)" in required["sam_model"][0] def test_sam_model_loader_uses_settings_aware_choices() -> None: diff --git a/tests/test_segs_from_sam_output_node.py b/tests/test_segs_from_sam_output_node.py new file mode 100644 index 0000000..b8129c0 --- /dev/null +++ b/tests/test_segs_from_sam_output_node.py @@ -0,0 +1,97 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the SEGS from SAM Output ComfyUI node.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.nodes.segs_from_sam_output import SEGSFromSAMOutput + + +def test_node_declares_automatic_sam_to_segs_contract() -> None: + """The node exposes standard IMAGE, SAM_MODEL, and SEGS sockets.""" + + inputs = SEGSFromSAMOutput.INPUT_TYPES()["required"] + + assert tuple(inputs) == ( + "image", + "sam_model", + "segmentation_resolution", + "minimum_region_area", + ) + assert inputs["segmentation_resolution"][1]["default"] == 640 + assert inputs["segmentation_resolution"][1]["step"] == 64 + assert SEGSFromSAMOutput.RETURN_TYPES == ("SEGS",) + assert SEGSFromSAMOutput.OUTPUT_IS_LIST == (True,) + + +def test_node_builds_one_segs_output_per_image_batch_item( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Automatic segmentation remains aligned with ComfyUI IMAGE batches.""" + + calls: list[torch.Tensor] = [] + phase_progress = _RecordingPhaseProgress() + + class _Service: + """Record image batches and return a distinct opaque SEGS payload.""" + + def build(self, **kwargs: object) -> object: + """Record one source image and return it as an opaque marker.""" + + image = kwargs["image"] + assert isinstance(image, torch.Tensor) + calls.append(image) + progress = kwargs["phase_progress"] + assert progress is phase_progress + for phase in ( + "preparing_segmentation_image", + "generating_automatic_masks", + "building_segs", + ): + phase_progress.advance(phase) + return (image.shape[1:3], ()) + + monkeypatch.setattr(SEGSFromSAMOutput, "service_class", _Service) + monkeypatch.setattr( + SEGSFromSAMOutput, + "progress_factory", + lambda **_kwargs: phase_progress, + ) + + (segs,) = SEGSFromSAMOutput().generate( + image=torch.zeros((2, 16, 16, 3)), + sam_model=object(), + segmentation_resolution=640, + minimum_region_area=0, + ) + + assert len(calls) == 2 + assert len(segs) == 2 + assert phase_progress.phases == [ + "preparing_segmentation_image", + "generating_automatic_masks", + "building_segs", + "preparing_segmentation_image", + "generating_automatic_masks", + "building_segs", + "completed", + ] + + +class _RecordingPhaseProgress: + """Record node progress without constructing a real ComfyUI progress bar.""" + + def __init__(self) -> None: + """Create an empty phase record.""" + + self.phases: list[str] = [] + + def advance(self, phase: str) -> None: + """Record one phase transition.""" + + self.phases.append(phase) diff --git a/tests/test_segs_from_sam_output_service.py b/tests/test_segs_from_sam_output_service.py new file mode 100644 index 0000000..5be626f --- /dev/null +++ b/tests/test_segs_from_sam_output_service.py @@ -0,0 +1,180 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for automatic SAM output conversion into standard SEGS.""" + +from __future__ import annotations + +from typing import cast + +import pytest +import torch + +from simple_syrup.runtime.sam_automatic_segmenter import AutomaticSAMMask +from simple_syrup.services.segs_from_sam_output_service import SEGSFromSAMOutputService + + +class _RecordingSegmenter: + """Return configured guide-space masks while recording the input shape.""" + + def __init__(self, masks: tuple[AutomaticSAMMask, ...]) -> None: + """Store masks for a deterministic automatic segmentation response.""" + + self._masks = masks + self.image_shapes: list[tuple[int, int]] = [] + + def segment_all( + self, + sam_model: object, + image: torch.Tensor, + execution_device: str = "auto", + ) -> tuple[AutomaticSAMMask, ...]: + """Record the guide image and return configured masks.""" + + del sam_model, execution_device + self.image_shapes.append((int(image.shape[1]), int(image.shape[2]))) + return self._masks + + +class _RecordingPhaseProgress: + """Record automatic-SEGS phase transitions without a ComfyUI dependency.""" + + def __init__(self) -> None: + """Create an empty phase record.""" + + self.phases: list[str] = [] + + def advance(self, phase: str) -> None: + """Record one named phase.""" + + self.phases.append(phase) + + +def test_service_downscales_the_segmentation_guide_without_upscaling_source() -> None: + """Guide resolution caps the long edge while preserving source SEGS geometry.""" + + guide_mask = torch.zeros((32, 64), dtype=torch.float32) + guide_mask[8:16, 16:32] = 1.0 + runtime = _RecordingSegmenter((AutomaticSAMMask(guide_mask, 0.8),)) + service = SEGSFromSAMOutputService(runtime) + + segs = service.build( + image=torch.zeros((1, 128, 256, 3)), + sam_model=object(), + segmentation_resolution=64, + minimum_region_area=0, + ) + + assert runtime.image_shapes == [(32, 64)] + assert segs[0] == (128, 256) + segment = segs[1][0] + assert segment.bbox == (64, 32, 128, 64) + assert segment.crop_region == (64, 32, 128, 64) + assert cast(torch.Tensor, segment.cropped_mask).shape == (32, 64) + assert segment.confidence == 0.8 + assert segment.label == "segment_001" + + +def test_service_reports_meaningful_automatic_segmentation_phases() -> None: + """The service exposes each expensive step to the node-owned progress bar.""" + + runtime = _RecordingSegmenter((AutomaticSAMMask(torch.ones((16, 16)), 1.0),)) + reporter = _RecordingPhaseProgress() + + SEGSFromSAMOutputService(runtime).build( + image=torch.zeros((1, 16, 16, 3)), + sam_model=object(), + segmentation_resolution=64, + minimum_region_area=0, + phase_progress=reporter, + ) + + assert reporter.phases == [ + "preparing_segmentation_image", + "generating_automatic_masks", + "building_segs", + ] + + +def test_service_filters_region_area_after_restoring_source_dimensions() -> None: + """Minimum area uses the original image instead of the guide image size.""" + + guide_mask = torch.zeros((32, 64), dtype=torch.float32) + guide_mask[8:10, 8:10] = 1.0 + runtime = _RecordingSegmenter((AutomaticSAMMask(guide_mask, 0.5, "thing"),)) + service = SEGSFromSAMOutputService(runtime) + + retained = service.build( + image=torch.zeros((1, 128, 256, 3)), + sam_model=object(), + segmentation_resolution=64, + minimum_region_area=63, + ) + filtered = service.build( + image=torch.zeros((1, 128, 256, 3)), + sam_model=object(), + segmentation_resolution=64, + minimum_region_area=65, + ) + + assert retained[1][0].label == "thing" + assert filtered[1] == () + + +def test_service_suppresses_duplicate_masks_and_keeps_highest_confidence() -> None: + """Repeated automatic masks do not create duplicate detailer targets.""" + + mask = torch.ones((16, 16), dtype=torch.float32) + runtime = _RecordingSegmenter( + ( + AutomaticSAMMask(mask, 0.4, "first"), + AutomaticSAMMask(mask, 0.9, "second"), + ) + ) + + segs = SEGSFromSAMOutputService(runtime).build( + image=torch.zeros((1, 16, 16, 3)), + sam_model=object(), + segmentation_resolution=64, + minimum_region_area=0, + ) + + assert len(segs[1]) == 1 + assert segs[1][0].confidence == 0.9 + assert segs[1][0].label == "second" + + +def test_service_expands_retained_mask_crops_to_source_resolution() -> None: + """Source-size SEGS creation never materializes a full-image mask per region.""" + + guide_mask = torch.zeros((64, 128), dtype=torch.float32) + guide_mask[16:32, 32:64] = 1.0 + + segs = SEGSFromSAMOutputService( + _RecordingSegmenter((AutomaticSAMMask(guide_mask, 1.0),)) + ).build( + image=torch.zeros((1, 1024, 2048, 3)), + sam_model=object(), + segmentation_resolution=128, + minimum_region_area=0, + ) + + segment = segs[1][0] + assert segment.crop_region == (512, 256, 1024, 512) + assert cast(torch.Tensor, segment.cropped_mask).shape == (256, 512) + + +@pytest.mark.parametrize("resolution", (0, 65, 127)) +def test_service_rejects_non_64_step_segmentation_resolution(resolution: int) -> None: + """Service validation preserves the node's 64-pixel resolution contract.""" + + service = SEGSFromSAMOutputService(_RecordingSegmenter(())) + + with pytest.raises(ValueError, match="segmentation_resolution"): + service.build( + image=torch.zeros((1, 16, 16, 3)), + sam_model=object(), + segmentation_resolution=resolution, + minimum_region_area=0, + ) diff --git a/tests/test_segs_tiled_diffusion.py b/tests/test_segs_tiled_diffusion.py new file mode 100644 index 0000000..ca35e3f --- /dev/null +++ b/tests/test_segs_tiled_diffusion.py @@ -0,0 +1,189 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for SEGS-guided irregular tiled diffusion planning.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment +from simple_syrup.domain.segs_tiled_diffusion import ( + _segment_mask_to_latent, + build_segs_guided_tiled_diffusion_plan, +) + + +def test_guided_plan_splits_oversized_region_with_bounded_rectangular_windows() -> None: + """A large region becomes multiple bounded cores instead of one giant tile.""" + + plan = build_segs_guided_tiled_diffusion_plan( + segs=_full_mask_segs(64, 64), + latent_width=64, + latent_height=64, + tile_width=32, + tile_height=32, + overlap=8, + tile_batch_size=3, + ) + + assert len(plan.tiles) > 1 + assert all(tile.width == 32 and tile.height == 32 for tile in plan.tiles) + assert all(tile.weight_mask is not None for tile in plan.tiles) + coverage = torch.zeros((64, 64), dtype=torch.float32) + for tile in plan.tiles: + assert tile.weight_mask is not None + coverage[tile.y : tile.y + tile.height, tile.x : tile.x + tile.width] += ( + tile.weight_mask + ) + assert bool((coverage > 0).all()) + + +def test_guided_plan_feathers_the_configured_overlap_between_irregular_cores() -> None: + """Non-zero overlap produces fractional shared ownership weights.""" + + plan = build_segs_guided_tiled_diffusion_plan( + segs=_half_mask_segs(64, 64), + latent_width=64, + latent_height=64, + tile_width=32, + tile_height=32, + overlap=8, + tile_batch_size=4, + ) + + weights = [tile.weight_mask for tile in plan.tiles if tile.weight_mask is not None] + assert any(bool(torch.any((weight > 0) & (weight < 1))) for weight in weights) + + +def test_guided_plan_prefers_smaller_overlapping_segs_for_ownership() -> None: + """A nested small SEG remains an ownership core instead of being swallowed.""" + + full = torch.ones((32, 32), dtype=torch.float32) + small = torch.zeros((32, 32), dtype=torch.float32) + small[8:24, 8:24] = 1.0 + segs = ( + (32, 32), + ( + _segment(full, "large", 0.6), + _segment(small, "small", 0.9), + ), + ) + + plan = build_segs_guided_tiled_diffusion_plan( + segs=segs, + latent_width=32, + latent_height=32, + tile_width=32, + tile_height=32, + overlap=0, + tile_batch_size=4, + ) + + assert len(plan.tiles) == 2 + assert all(tile.weight_mask is not None for tile in plan.tiles) + + +def test_guided_plan_merges_small_cores_without_comparing_tensor_values() -> None: + """Small nearby cores merge through their indexes instead of tensor equality.""" + + first = torch.zeros((32, 32), dtype=torch.float32) + second = torch.zeros((32, 32), dtype=torch.float32) + first[4:8, 4:8] = 1.0 + second[8:12, 8:12] = 1.0 + segs = ( + (32, 32), + ( + _segment(first, "first", 1.0), + _segment(second, "second", 1.0), + ), + ) + + plan = build_segs_guided_tiled_diffusion_plan( + segs=segs, + latent_width=32, + latent_height=32, + tile_width=32, + tile_height=32, + overlap=0, + tile_batch_size=4, + ) + + assert len(plan.tiles) == 1 + + +def test_crop_local_mask_projects_directly_to_latent_space() -> None: + """A small crop maps without materializing a full source-resolution mask.""" + + crop = CropRegion(512, 256, 768, 512) + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((256, 256), dtype=torch.float32), + confidence=1.0, + crop_region=crop, + bbox=BoundingBox(*crop), + label="small_region", + ) + + latent_mask = _segment_mask_to_latent( + segment, + source_height=4096, + source_width=4096, + latent_width=512, + latent_height=512, + ) + + assert int(latent_mask.sum().item()) == 32 * 32 + assert bool(latent_mask[32:64, 64:96].all()) + assert not bool(latent_mask[:32].any()) + assert not bool(latent_mask[:, :64].any()) + + +def test_guided_plan_rejects_mismatched_image_aspect_ratio() -> None: + """SEGS from a different image fail before tiled sampling begins.""" + + with pytest.raises(ValueError, match="aspect ratio"): + build_segs_guided_tiled_diffusion_plan( + segs=_full_mask_segs(16, 32), + latent_width=32, + latent_height=32, + tile_width=16, + tile_height=16, + overlap=4, + tile_batch_size=2, + ) + + +def _full_mask_segs( + height: int, width: int +) -> tuple[tuple[int, int], tuple[Segment, ...]]: + """Return one full-image SEG payload.""" + + return ((height, width), (_segment(torch.ones((height, width)), "region", 1.0),)) + + +def _half_mask_segs( + height: int, width: int +) -> tuple[tuple[int, int], tuple[Segment, ...]]: + """Return one left-half SEG payload with implicit background ownership.""" + + mask = torch.zeros((height, width), dtype=torch.float32) + mask[:, : width // 2] = 1.0 + return ((height, width), (_segment(mask, "left", 1.0),)) + + +def _segment(mask: torch.Tensor, label: str, confidence: float) -> Segment: + """Build one full-image Segment whose crop equals the source dimensions.""" + + height, width = mask.shape + region = CropRegion(0, 0, width, height) + return Segment( + cropped_image=None, + cropped_mask=mask, + confidence=confidence, + crop_region=region, + bbox=BoundingBox(0, 0, width, height), + label=label, + ) diff --git a/tests/test_tiled_diffusion_sampling_service.py b/tests/test_tiled_diffusion_sampling_service.py index fcc002b..5a85164 100644 --- a/tests/test_tiled_diffusion_sampling_service.py +++ b/tests/test_tiled_diffusion_sampling_service.py @@ -12,6 +12,7 @@ import pytest import torch from simple_syrup.domain.conditioning_batch import ConditioningBatch +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment from simple_syrup.services.tiled_diffusion_sampling_service import ( TiledDiffusionSamplingService, ) @@ -127,7 +128,7 @@ def test_service_forwards_sampling_arguments_unchanged( assert result is output assert calls == { key: value for key, value in kwargs.items() if key != "diffusion_mode" - } + } | {"tiled_plan": None} def test_service_forwards_differential_diffusion_request( @@ -216,6 +217,38 @@ def test_service_selects_conditioning_batch_per_latent_item( assert torch.equal(result["samples"][1], torch.full((4, 4, 4), 2.0)) +def test_service_builds_and_forwards_a_segs_guided_plan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Connected SEGS replace the regular grid with a semantic tile plan.""" + + calls: list[dict[str, Any]] = [] + + def fake_multidiffusion(**kwargs: Any) -> dict[str, Any]: + """Record the semantic plan and return the item unchanged.""" + + calls.append(kwargs) + return {"samples": kwargs["latent_image"]["samples"]} + + monkeypatch.setattr( + "simple_syrup.services.tiled_diffusion_sampling_service." + "multidiffusion_sampling.sample_multidiffusion", + fake_multidiffusion, + ) + segs = ((4, 4), (_full_segment(4, 4),)) + + TiledDiffusionSamplingService().sample( + **(_sample_kwargs(diffusion_mode="multidiffusion") | {"segs": segs}) + ) + + assert len(calls) == 1 + plan = calls[0]["tiled_plan"] + assert plan is not None + assert plan.latent_width == 4 + assert plan.latent_height == 4 + assert all(tile.weight_mask is not None for tile in plan.tiles) + + def test_invalid_mode_fails_before_runtime_call( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -271,3 +304,17 @@ def _sample_kwargs( "differential_diffusion": False, "allow_full_context_masks": False, } + + +def _full_segment(height: int, width: int) -> Segment: + """Return one full-image SEG compatible with the sample latent dimensions.""" + + region = CropRegion(0, 0, width, height) + return Segment( + cropped_image=None, + cropped_mask=torch.ones((height, width), dtype=torch.float32), + confidence=1.0, + crop_region=region, + bbox=BoundingBox(0, 0, width, height), + label="region", + ) diff --git a/tests/test_tiled_sampling_runtime.py b/tests/test_tiled_sampling_runtime.py index d78a398..36fbe78 100644 --- a/tests/test_tiled_sampling_runtime.py +++ b/tests/test_tiled_sampling_runtime.py @@ -185,6 +185,30 @@ def test_new_spatial_weight_buffer_broadcasts_over_spatial_axes() -> None: ).shape == (1, 1, 1, 4, 8) +def test_semantic_tile_weight_cache_reuses_resident_weights() -> None: + """Semantic tile weights are materialized once for repeated model outputs.""" + + tile = LatentTile( + x=0, + y=0, + width=4, + height=4, + weight_mask=torch.ones((4, 4), dtype=torch.float32), + ) + cache = tiled_sampling.SemanticTileWeightCache((tile,)) + output = torch.zeros((1, 4, 4, 4), dtype=torch.float16) + + first = cache.for_output(output) + second = cache.for_output(output) + model_weight, accumulation_weight = cache.for_tile(first, tile) + + assert first is second + assert model_weight is first.model[0] + assert accumulation_weight is first.accumulation[0] + assert model_weight.dtype == torch.float16 + assert accumulation_weight.dtype == torch.float32 + + def test_contains_unsupported_conditioning_key_finds_nested_values() -> None: """Unsupported regional and control keys are detected recursively.""" From b58539097a4b5349fec4b7cfc0122da0bc4ad40b Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sun, 2 Aug 2026 01:15:16 -0400 Subject: [PATCH 26/40] feat(sampling): add contextual diffusion sampler --- simple_syrup/domain/contextual_diffusion.py | 146 +++++++ simple_syrup/domain/segs_tiled_diffusion.py | 36 +- .../nodes/ksampler_contextual_diffusion.py | 228 +++++++++++ simple_syrup/nodes/ksampler_extras.py | 2 +- .../nodes/ksampler_tiled_diffusion.py | 6 +- simple_syrup/nodes/tooltips.py | 31 ++ simple_syrup/nodes_v3/__init__.py | 2 + simple_syrup/nodes_v3/legacy_node_wrappers.py | 10 + .../runtime/contextual_diffusion_sampling.py | 362 ++++++++++++++++++ simple_syrup/runtime/detail_sampling.py | 16 +- .../runtime/mixture_of_diffusers_sampling.py | 87 +---- .../runtime/multidiffusion_sampling.py | 64 +--- .../regional_multidiffusion_sampling.py | 16 +- simple_syrup/runtime/sampling_schedulers.py | 90 ++++- simple_syrup/runtime/tiled_sampling.py | 299 ++++++++++++++- .../contextual_diffusion_sampling_service.py | 177 +++++++++ .../services/ksampler_sampling_service.py | 15 +- simple_syrup/services/sampling_batch.py | 56 +++ .../tiled_diffusion_sampling_service.py | 47 +-- tests/test_contextual_diffusion_domain.py | 98 +++++ tests/test_contextual_diffusion_sampling.py | 289 ++++++++++++++ ...t_contextual_diffusion_sampling_service.py | 178 +++++++++ ...test_ksampler_contextual_diffusion_node.py | 152 ++++++++ tests/test_ksampler_extras_node.py | 8 + tests/test_mixture_of_diffusers_sampling.py | 3 + tests/test_multidiffusion_sampling.py | 3 + tests/test_persisted_widget_order_contract.py | 21 + tests/test_registration.py | 1 + tests/test_sampling_schedulers.py | 74 +++- tests/test_segs_tiled_diffusion.py | 29 +- tests/test_tiled_sampling_runtime.py | 83 ++++ 31 files changed, 2420 insertions(+), 209 deletions(-) create mode 100644 simple_syrup/domain/contextual_diffusion.py create mode 100644 simple_syrup/nodes/ksampler_contextual_diffusion.py create mode 100644 simple_syrup/runtime/contextual_diffusion_sampling.py create mode 100644 simple_syrup/services/contextual_diffusion_sampling_service.py create mode 100644 simple_syrup/services/sampling_batch.py create mode 100644 tests/test_contextual_diffusion_domain.py create mode 100644 tests/test_contextual_diffusion_sampling.py create mode 100644 tests/test_contextual_diffusion_sampling_service.py create mode 100644 tests/test_ksampler_contextual_diffusion_node.py diff --git a/simple_syrup/domain/contextual_diffusion.py b/simple_syrup/domain/contextual_diffusion.py new file mode 100644 index 0000000..b92aa3f --- /dev/null +++ b/simple_syrup/domain/contextual_diffusion.py @@ -0,0 +1,146 @@ +# 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 + +from .segs import NativeSegs +from .segs_tiled_diffusion import build_segs_guided_tiled_diffusion_plan +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 + + 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.") + if not 0 <= self.latent_context_overlap < self.latent_context_size: + raise ValueError( + "latent_context_overlap must be non-negative and smaller than " + "latent_context_size." + ) + 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 SpatialContext: + """Describe one source rectangle evaluated at a bounded model context shape.""" + + x: int + y: int + width: int + height: int + context_width: int + context_height: int + + +@dataclass(frozen=True) +class ContextualDiffusionPlan: + """Own the global context and sole tiled plan for one latent canvas.""" + + latent_width: int + latent_height: int + global_context: SpatialContext + tile_plan: TiledDiffusionPlan + + +def build_contextual_diffusion_plan( + *, + latent_width: int, + latent_height: int, + controls: ContextualDiffusionControls, + segs: NativeSegs | 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_context = SpatialContext( + x=0, + y=0, + width=latent_width, + height=latent_height, + context_width=global_width, + context_height=global_height, + ) + tile_plan = ( + build_segs_guided_tiled_diffusion_plan( + segs=segs, + latent_width=latent_width, + latent_height=latent_height, + tile_width=controls.latent_context_size, + tile_height=controls.latent_context_size, + overlap=controls.latent_context_overlap, + tile_batch_size=controls.latent_context_batch_size, + ) + if segs is not None + else build_tiled_diffusion_plan( + latent_width=latent_width, + latent_height=latent_height, + tile_width=controls.latent_context_size, + tile_height=controls.latent_context_size, + overlap=controls.latent_context_overlap, + tile_batch_size=controls.latent_context_batch_size, + ) + ) + return ContextualDiffusionPlan( + latent_width=latent_width, + latent_height=latent_height, + global_context=global_context, + 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 diff --git a/simple_syrup/domain/segs_tiled_diffusion.py b/simple_syrup/domain/segs_tiled_diffusion.py index 89e5a4a..d3f528e 100644 --- a/simple_syrup/domain/segs_tiled_diffusion.py +++ b/simple_syrup/domain/segs_tiled_diffusion.py @@ -55,7 +55,7 @@ def build_segs_guided_tiled_diffusion_plan( tile_batch_size=tile_batch_size, ) native_segs = coerce_segs(segs) - _validate_aspect_ratio(native_segs, latent_height, latent_width) + validate_segs_aspect_ratio(native_segs, latent_height, latent_width) ownership = _build_ownership_cores( native_segs, latent_height=latent_height, @@ -107,7 +107,7 @@ def build_segs_guided_tiled_diffusion_plan( ) -def _validate_aspect_ratio( +def validate_segs_aspect_ratio( segs: NativeSegs, latent_height: int, latent_width: int, @@ -136,7 +136,7 @@ def _build_ownership_cores( source_height, source_width = segs[0] segment_masks = tuple( - _segment_mask_to_latent( + segment_mask_to_latent( segment, source_height=source_height, source_width=source_width, @@ -170,7 +170,7 @@ def _build_ownership_cores( ) -def _segment_mask_to_latent( +def segment_mask_to_latent( segment: Segment, *, source_height: int, @@ -180,6 +180,28 @@ def _segment_mask_to_latent( ) -> 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 @@ -215,7 +237,7 @@ def _segment_mask_to_latent( source_width, latent_width, ) - latent_mask = torch.zeros((latent_height, latent_width), dtype=torch.bool) + 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 = ( @@ -242,9 +264,7 @@ def _segment_mask_to_latent( ) .index_select(1, sampled_columns) ) - latent_mask[latent_top:latent_bottom, latent_left:latent_right] = ( - sampled_mask >= 0.5 - ) + latent_mask[latent_top:latent_bottom, latent_left:latent_right] = sampled_mask return latent_mask diff --git a/simple_syrup/nodes/ksampler_contextual_diffusion.py b/simple_syrup/nodes/ksampler_contextual_diffusion.py new file mode 100644 index 0000000..d187af2 --- /dev/null +++ b/simple_syrup/nodes/ksampler_contextual_diffusion.py @@ -0,0 +1,228 @@ +# 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 contextual diffusion sampling.""" + +from __future__ import annotations + +from typing import Any, ClassVar, TypeAlias + +from ..domain.tiled_diffusion import TILED_DIFFUSION_MODES +from ..runtime import sampling_samplers, sampling_schedulers +from ..services.contextual_diffusion_sampling_service import ( + ContextualDiffusionSamplingService, +) +from . import tooltips + +Latent: TypeAlias = dict[str, Any] +MAX_LATENT_CONTEXT_SIZE = 512 + + +class KSamplerContextualDiffusion: + """Edit large latents through coordinated global and detailed contexts.""" + + RETURN_TYPES = ("LATENT",) + OUTPUT_TOOLTIPS = (tooltips.DENOISED_LATENT_OUTPUT,) + FUNCTION = "sample" + CATEGORY = "SimpleSyrup/Sampling" + DESCRIPTION = ( + "Preserves composition while applying appearance and subject-detail edits " + "to large latents through global context and optional SEGS-guided tiles." + ) + SEARCH_ALIASES = [ + "ksampler", + "contextual diffusion", + "contextual tiled diffusion", + "high resolution edit", + "sam tiled diffusion", + ] + + service_class: ClassVar[type[ContextualDiffusionSamplingService]] = ( + ContextualDiffusionSamplingService + ) + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare KSampler inputs and bounded contextual controls.""" + + return { + "required": { + "model": ("MODEL", {"tooltip": tooltips.SAMPLING_MODEL}), + "seed": ( + "INT", + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": tooltips.SAMPLING_SEED, + }, + ), + "steps": ( + "INT", + { + "default": 4, + "min": 1, + "max": 10000, + "tooltip": tooltips.SAMPLING_STEPS, + }, + ), + "cfg": ( + "FLOAT", + { + "default": 1.0, + "min": 0.0, + "max": 100.0, + "step": 0.1, + "round": 0.01, + "tooltip": tooltips.SAMPLING_CFG, + }, + ), + "sampler_name": ( + sampling_samplers.available_samplers(), + {"tooltip": tooltips.SAMPLER_NAME}, + ), + "scheduler": ( + sampling_schedulers.available_schedulers(), + {"tooltip": tooltips.SCHEDULER}, + ), + "positive": ( + "CONDITIONING,CONDITIONING_BATCH", + {"tooltip": tooltips.POSITIVE_CONDITIONING}, + ), + "negative": ( + "CONDITIONING,CONDITIONING_BATCH", + {"tooltip": tooltips.NEGATIVE_CONDITIONING}, + ), + "latent_image": ("LATENT", {"tooltip": tooltips.LATENT_IMAGE}), + "denoise": ( + "FLOAT", + { + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": tooltips.DENOISE_STRENGTH, + }, + ), + "diffusion_mode": ( + list(TILED_DIFFUSION_MODES), + { + "default": "multidiffusion", + "tooltip": tooltips.TILED_DIFFUSION_MODE, + }, + ), + "latent_context_size": ( + "INT", + { + "default": 96, + "min": 16, + "max": MAX_LATENT_CONTEXT_SIZE, + "step": 16, + "tooltip": tooltips.LATENT_CONTEXT_SIZE, + }, + ), + "latent_context_overlap": ( + "INT", + { + "default": 32, + "min": 0, + "max": 256, + "step": 4, + "tooltip": tooltips.LATENT_CONTEXT_OVERLAP, + }, + ), + "latent_context_batch_size": ( + "INT", + { + "default": 4, + "min": 1, + "max": 8, + "step": 1, + "tooltip": tooltips.LATENT_CONTEXT_BATCH_SIZE, + }, + ), + "global_weight": ( + "FLOAT", + { + "default": 1.0, + "min": 0.0, + "max": 2.0, + "step": 0.05, + "tooltip": tooltips.GLOBAL_CONTEXT_WEIGHT, + }, + ), + "global_steps": ( + "INT", + { + "default": 1, + "min": 0, + "max": 10000, + "step": 1, + "tooltip": tooltips.GLOBAL_CONTEXT_STEPS, + }, + ), + "global_decay": ( + "FLOAT", + { + "default": 0.5, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "tooltip": tooltips.GLOBAL_CONTEXT_DECAY, + }, + ), + }, + "optional": { + "segs": ( + "SEGS", + {"tooltip": tooltips.CONTEXTUAL_DIFFUSION_SEGS}, + ) + }, + } + + def sample( + self, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float = 1.0, + diffusion_mode: str = "multidiffusion", + latent_context_size: int = 96, + latent_context_overlap: int = 32, + latent_context_batch_size: int = 4, + global_weight: float = 1.0, + global_steps: int = 1, + global_decay: float = 0.5, + segs: object | None = None, + ) -> tuple[Latent]: + """Delegate contextual diffusion sampling to its application service.""" + + output = self.service_class().sample( + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + denoise=denoise, + diffusion_mode=diffusion_mode, + latent_context_size=latent_context_size, + latent_context_overlap=latent_context_overlap, + latent_context_batch_size=latent_context_batch_size, + global_weight=global_weight, + global_steps=global_steps, + global_decay=global_decay, + segs=segs, + ) + return (output,) diff --git a/simple_syrup/nodes/ksampler_extras.py b/simple_syrup/nodes/ksampler_extras.py index b78d8c5..0adee61 100644 --- a/simple_syrup/nodes/ksampler_extras.py +++ b/simple_syrup/nodes/ksampler_extras.py @@ -16,7 +16,7 @@ Latent = dict[str, Any] class KSamplerExtras: - """Expose KSampler-style sampling with AYS and GITS scheduler options.""" + """Expose KSampler-style sampling with extended scheduler options.""" RETURN_TYPES = ("LATENT",) OUTPUT_TOOLTIPS = (tooltips.DENOISED_LATENT_OUTPUT,) diff --git a/simple_syrup/nodes/ksampler_tiled_diffusion.py b/simple_syrup/nodes/ksampler_tiled_diffusion.py index 4454e3a..b8ecab7 100644 --- a/simple_syrup/nodes/ksampler_tiled_diffusion.py +++ b/simple_syrup/nodes/ksampler_tiled_diffusion.py @@ -106,11 +106,7 @@ class KSamplerTiledDiffusion: list(TILED_DIFFUSION_MODES), { "default": "multidiffusion", - "tooltip": ( - "Tiled sampling blend method. MultiDiffusion is steady; " - "Mixture of Diffusers can blend tile predictions more " - "softly." - ), + "tooltip": tooltips.TILED_DIFFUSION_MODE, }, ), "latent_tile_width": ( diff --git a/simple_syrup/nodes/tooltips.py b/simple_syrup/nodes/tooltips.py index 3d970f3..b4ac98b 100644 --- a/simple_syrup/nodes/tooltips.py +++ b/simple_syrup/nodes/tooltips.py @@ -137,6 +137,10 @@ DENOISE_STRENGTH = ( "larger changes." ) DENOISED_LATENT_OUTPUT = "Denoised latent for VAE decode or more latent processing." +TILED_DIFFUSION_MODE = ( + "Tile overlap blend. MultiDiffusion averages predictions; Mixture of Diffusers " + "gives tile centers more influence." +) LATENT_TILE_WIDTH = ( "Width of each latent tile. Larger tiles see more context but use more memory." @@ -152,6 +156,33 @@ LATENT_TILE_BATCH_SIZE = ( "Number of latent tiles sampled together. Higher values can be faster but use " "more memory." ) +LATENT_CONTEXT_SIZE = ( + "Maximum side of each model context in latent pixels. Larger contexts preserve " + "more relationships but use more memory." +) +LATENT_CONTEXT_OVERLAP = ( + "Overlap between local latent contexts in latent pixels. Larger overlaps reduce " + "seams but increase sampling work." +) +LATENT_CONTEXT_BATCH_SIZE = ( + "Number of equal-sized latent contexts sampled together. Higher values can be " + "faster but use more memory." +) +GLOBAL_CONTEXT_WEIGHT = ( + "Strength of whole-image low-frequency guidance. 1 makes the global context " + "authoritative; lower values allow more tile interpretation." +) +GLOBAL_CONTEXT_STEPS = ( + "Number of initial denoising steps that use the global context. Fewer steps leave " + "more late sampling for local detail." +) +GLOBAL_CONTEXT_DECAY = ( + "Multiplier applied to whole-image strength after each global step. Lower " + "values hand control to local contexts faster." +) +CONTEXTUAL_DIFFUSION_SEGS = ( + "Optional regions that replace the regular grid with SEGS-guided contexts." +) DETAIL_IMAGE = ( "Source image containing the regions to improve. Detailed crops are blended " diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index 3bbfa89..b282d50 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -28,6 +28,7 @@ def get_nodes() -> list[type[object]]: EncodePromptBatchV3, GroundedSAMModelInfoV3, GroundingDINOModelLoaderV3, + KSamplerContextualDiffusionV3, KSamplerExtrasV3, KSamplerTiledDiffusionV3, LatentDiagnosticsV3, @@ -73,6 +74,7 @@ def get_nodes() -> list[type[object]]: KSamplerExtrasV3, KSamplerPromptByRegionV3, KSamplerPromptByTiledRegionV3, + KSamplerContextualDiffusionV3, KSamplerTiledDiffusionV3, LatentDiagnosticsV3, LayerStyleSAMModelsAdapterV3, diff --git a/simple_syrup/nodes_v3/legacy_node_wrappers.py b/simple_syrup/nodes_v3/legacy_node_wrappers.py index 1c29167..4e075ad 100644 --- a/simple_syrup/nodes_v3/legacy_node_wrappers.py +++ b/simple_syrup/nodes_v3/legacy_node_wrappers.py @@ -24,6 +24,7 @@ from ..nodes.encode_prompt_batch import EncodePromptBatch from ..nodes.grounded_sam_model_info import GroundedSAMModelInfo from ..nodes.grounding_dino_model_loader import GroundingDINOModelLoader from ..nodes.image_resize_to_target import ResizeImageToTarget +from ..nodes.ksampler_contextual_diffusion import KSamplerContextualDiffusion from ..nodes.ksampler_extras import KSamplerExtras from ..nodes.ksampler_tiled_diffusion import KSamplerTiledDiffusion from ..nodes.latent_diagnostics import LatentDiagnostics @@ -157,6 +158,14 @@ class KSamplerTiledDiffusionV3(LegacyNodeV3Adapter): DISPLAY_NAME = "KSampler (Tiled Diffusion)" +class KSamplerContextualDiffusionV3(LegacyNodeV3Adapter): + """Expose KSampler Contextual Diffusion through Comfy v3 only.""" + + LEGACY_NODE_CLASS = KSamplerContextualDiffusion + NODE_ID = "SimpleSyrup.KSamplerContextualDiffusion" + DISPLAY_NAME = "KSampler (Contextual Diffusion)" + + class LayerStyleSAMModelsAdapterV3(LegacyNodeV3Adapter): """Expose LayerStyle SAM Models Adapter through Comfy v3 only.""" @@ -505,6 +514,7 @@ __all__ = [ "GroundedSAMModelInfoV3", "GroundingDINOModelLoaderV3", "KSamplerExtrasV3", + "KSamplerContextualDiffusionV3", "KSamplerTiledDiffusionV3", "LatentDiagnosticsV3", "LayerStyleSAMModelsAdapterV3", diff --git a/simple_syrup/runtime/contextual_diffusion_sampling.py b/simple_syrup/runtime/contextual_diffusion_sampling.py new file mode 100644 index 0000000..53401ce --- /dev/null +++ b/simple_syrup/runtime/contextual_diffusion_sampling.py @@ -0,0 +1,362 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI runtime adapter for contextual diffusion latent sampling.""" + +from __future__ import annotations + +from importlib import import_module +from types import ModuleType +from typing import Any, cast + +import torch + +from ..domain.contextual_diffusion import ( + ContextualDiffusionControls, + ContextualDiffusionPlan, +) +from ..shared.logging import get_logger +from . import sampling_samplers, sampling_schedulers +from .tiled_sampling import ( + ApplyModel, + Latent, + ModelFunctionWrapper, + TilePredictionAccumulator, + make_spatial_context_model_args, + reject_unsupported_conditioning, + resize_spatial_tensor, + validate_latent_samples, + validate_sampling_controls, + validate_tensor_shape, +) + +LOGGER = get_logger(__name__) +SAMPLER_LABEL = "Contextual Diffusion" +UNIPC_SAMPLERS = frozenset({"uni_pc", "uni_pc_bh2"}) + + +def sample_contextual_diffusion( + *, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + diffusion_mode: str, + controls: ContextualDiffusionControls, + plan: ContextualDiffusionPlan, +) -> Latent: + """Sample one latent through global context and one tiled prediction plan.""" + + validate_sampling_controls( + steps=steps, + denoise=denoise, + latent_tile_width=controls.latent_context_size, + latent_tile_height=controls.latent_context_size, + latent_tile_batch_size=controls.latent_context_batch_size, + ) + controls.validate() + if sampler_name in UNIPC_SAMPLERS: + raise ValueError("Contextual Diffusion is not compatible with UniPC samplers.") + reject_unsupported_conditioning(positive, sampler_label=SAMPLER_LABEL) + reject_unsupported_conditioning(negative, sampler_label=SAMPLER_LABEL) + + sampler = sampling_samplers.resolve_sampler(sampler_name) + sigmas = sampling_schedulers.calculate_sigmas( + model=model, + scheduler_name=scheduler, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + view=sampling_schedulers.SchedulerView( + latent_width=controls.latent_context_size, + latent_height=controls.latent_context_size, + ), + ).to(model.load_device) + latent_samples = validate_latent_samples(latent_image, sampler_label=SAMPLER_LABEL) + comfy_sample = _comfy_sample() + comfy_utils = _comfy_utils() + latent_samples = comfy_sample.fix_empty_latent_channels( + model, + latent_samples, + latent_image.get("downscale_ratio_spacial", None), + ) + validate_tensor_shape(latent_samples, sampler_label=SAMPLER_LABEL) + if latent_samples.shape[-2:] != (plan.latent_height, plan.latent_width): + raise ValueError( + "Contextual Diffusion plan dimensions must match the sampled latent shape." + ) + + sampling_model = clone_model_with_contextual_diffusion( + model, + plan=plan, + controls=controls, + sigmas=sigmas, + diffusion_mode=diffusion_mode, + ) + batch_inds = latent_image.get("batch_index") + noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds) + callback = _latent_preview().prepare_callback(sampling_model, steps) + samples = comfy_sample.sample_custom( + sampling_model, + noise, + cfg, + sampler, + sigmas, + positive, + negative, + latent_samples, + noise_mask=latent_image.get("noise_mask"), + callback=callback, + disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED, + seed=seed, + ) + + LOGGER.info( + "KSampler Contextual Diffusion pass completed", + extra={ + "operation": "ksampler_contextual_diffusion", + "sampler": sampler_name, + "scheduler": scheduler, + "steps": steps, + "denoise": denoise, + "diffusion_mode": diffusion_mode, + "latent_width": plan.latent_width, + "latent_height": plan.latent_height, + "context_size": controls.latent_context_size, + "overlap": controls.latent_context_overlap, + "tile_count": len(plan.tile_plan.tiles), + "segs_guided": any( + tile.weight_mask is not None for tile in plan.tile_plan.tiles + ), + "model_calls_per_prediction": ( + 1 + if len(plan.tile_plan.tiles) == 1 + else len(plan.tile_plan.batches) + int(controls.global_weight > 0) + ), + }, + ) + output = latent_image.copy() + output.pop("downscale_ratio_spacial", None) + output["samples"] = samples + return output + + +def clone_model_with_contextual_diffusion( + model: Any, + *, + plan: ContextualDiffusionPlan, + controls: ContextualDiffusionControls, + sigmas: torch.Tensor, + diffusion_mode: str, +) -> Any: + """Clone a model and install one pre-CFG contextual prediction wrapper.""" + + cloned_model = model.clone() + old_wrapper = cloned_model.model_options.get("model_function_wrapper") + if old_wrapper is not None and not callable(old_wrapper): + raise ValueError("Existing model_function_wrapper is not callable.") + wrapper = ContextualDiffusionModelWrapper( + plan=plan, + controls=controls, + sigmas=sigmas, + diffusion_mode=diffusion_mode, + existing_wrapper=cast(ModelFunctionWrapper | None, old_wrapper), + ) + cloned_model.set_model_unet_function_wrapper(wrapper) + return cloned_model + + +class ContextualDiffusionModelWrapper: + """Fuse one authoritative tiled prediction with scheduled global structure.""" + + def __init__( + self, + *, + plan: ContextualDiffusionPlan, + controls: ContextualDiffusionControls, + sigmas: torch.Tensor, + diffusion_mode: str = "multidiffusion", + existing_wrapper: ModelFunctionWrapper | None, + ) -> None: + """Create a wrapper for one immutable latent and context plan.""" + + self._plan = plan + self._controls = controls + self._global_schedule = GlobalContextSchedule( + sigmas=sigmas, + active_steps=controls.global_steps, + decay=controls.global_decay, + ) + self._existing_wrapper = existing_wrapper + self._diffusion_mode = diffusion_mode + self._tile_predictions = TilePredictionAccumulator( + plan.tile_plan, + diffusion_mode=diffusion_mode, + ) + + @property + def diffusion_mode(self) -> str: + """Return the selected local tile blending policy.""" + + return self._diffusion_mode + + def __call__( + self, + apply_model: ApplyModel, + args: dict[str, Any], + ) -> torch.Tensor: + """Evaluate bounded contexts and return one canvas-sized prediction.""" + + x = args.get("input") + if not isinstance(x, torch.Tensor): + raise ValueError("Contextual Diffusion model input must be a tensor.") + validate_tensor_shape(x, sampler_label=SAMPLER_LABEL) + if x.shape[-2:] != (self._plan.latent_height, self._plan.latent_width): + return self._call_original(apply_model, args) + if len(self._plan.tile_plan.tiles) == 1: + return self._call_original(apply_model, args) + + tile_prediction = self._predict_tiles(apply_model, args, x) + global_scale = self._global_schedule.scale_for(args.get("timestep")) + return ( + self._apply_global_authority( + apply_model, + args, + tile_prediction, + weight=self._controls.global_weight * global_scale, + ) + if global_scale > 0 and self._controls.global_weight > 0 + else tile_prediction + ) + + def _predict_tiles( + self, + apply_model: ApplyModel, + args: dict[str, Any], + x: torch.Tensor, + ) -> torch.Tensor: + """Blend the authoritative regular or SEGS-guided tiled prediction.""" + + return self._tile_predictions.predict( + args=args, + x=x, + evaluate=lambda tiled_args: self._call_original(apply_model, tiled_args), + ) + + def _apply_global_authority( + self, + apply_model: ApplyModel, + args: dict[str, Any], + local_prediction: torch.Tensor, + *, + weight: float, + ) -> torch.Tensor: + """Replace tile-scale scene interpretation with the whole-image prediction.""" + + global_args = make_spatial_context_model_args( + args=args, + contexts=(self._plan.global_context,), + input_batch_size=int(local_prediction.shape[0]), + latent_height=self._plan.latent_height, + latent_width=self._plan.latent_width, + ) + global_prediction = self._call_original(apply_model, global_args) + context = self._plan.global_context + global_canvas = resize_spatial_tensor( + global_prediction, + height=self._plan.latent_height, + width=self._plan.latent_width, + mode="bilinear", + ) + local_low = resize_spatial_tensor( + local_prediction, + height=context.context_height, + width=context.context_width, + mode="area", + ) + local_low = resize_spatial_tensor( + local_low, + height=self._plan.latent_height, + width=self._plan.latent_width, + mode="bilinear", + ) + return local_prediction + weight * (global_canvas - local_low) + + def _call_original( + self, + apply_model: ApplyModel, + args: dict[str, Any], + ) -> torch.Tensor: + """Call the preserved model wrapper or raw apply_model.""" + + if self._existing_wrapper is not None: + return self._existing_wrapper(apply_model, args) + conditioning = args.get("c", {}) + if not isinstance(conditioning, dict): + raise ValueError("Contextual Diffusion conditioning must be a dict.") + return apply_model(args["input"], args["timestep"], **conditioning) + + +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 + + +def _comfy_sample() -> ModuleType: + """Import ComfyUI sample helpers lazily.""" + + return import_module("comfy.sample") + + +def _comfy_utils() -> ModuleType: + """Import ComfyUI progress state lazily.""" + + return import_module("comfy.utils") + + +def _latent_preview() -> ModuleType: + """Import ComfyUI preview helpers lazily.""" + + return import_module("latent_preview") diff --git a/simple_syrup/runtime/detail_sampling.py b/simple_syrup/runtime/detail_sampling.py index 0044cc7..f19e326 100644 --- a/simple_syrup/runtime/detail_sampling.py +++ b/simple_syrup/runtime/detail_sampling.py @@ -60,14 +60,6 @@ class DetailSampler: """Sample a latent with SimpleSyrup's sampler and scheduler helpers.""" sampler = sampling_samplers.resolve_sampler(sampler_name) - sigmas = sampling_schedulers.calculate_sigmas( - model=model, - scheduler_name=scheduler, - sampler_name=sampler_name, - steps=steps, - denoise=denoise, - ).to(model.load_device) - latent_samples = cast(torch.Tensor, latent_image["samples"]) comfy_sample = _comfy_sample() comfy_utils = _comfy_utils() @@ -77,6 +69,14 @@ class DetailSampler: latent_samples, latent_image.get("downscale_ratio_spacial", None), ) + sigmas = sampling_schedulers.calculate_sigmas( + model=model, + scheduler_name=scheduler, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + view=sampling_schedulers.SchedulerView.from_tensor(latent_samples), + ).to(model.load_device) batch_inds = ( latent_image["batch_index"] if "batch_index" in latent_image else None ) diff --git a/simple_syrup/runtime/mixture_of_diffusers_sampling.py b/simple_syrup/runtime/mixture_of_diffusers_sampling.py index 249a9a4..917b7ac 100644 --- a/simple_syrup/runtime/mixture_of_diffusers_sampling.py +++ b/simple_syrup/runtime/mixture_of_diffusers_sampling.py @@ -10,7 +10,6 @@ from __future__ import annotations -from collections.abc import Sequence from importlib import import_module from types import ModuleType from typing import Any, cast @@ -18,10 +17,8 @@ from typing import Any, cast import torch from ..domain.tiled_diffusion import ( - LatentTile, TiledDiffusionPlan, build_tiled_diffusion_plan, - gaussian_tile_weights, ) from ..shared.logging import get_logger from . import sampling_samplers, sampling_schedulers @@ -31,11 +28,8 @@ from .tiled_sampling import ( ApplyModel, Latent, ModelFunctionWrapper, - SemanticTileWeightCache, - make_tiled_model_args, - new_spatial_weight_buffer, + TilePredictionAccumulator, reject_unsupported_conditioning, - spatial_tile_slicer, validate_latent_samples, validate_sampling_controls, validate_tensor_shape, @@ -93,6 +87,10 @@ def sample_mixture_of_diffusers( sampler_name=sampler_name, steps=steps, denoise=denoise, + view=sampling_schedulers.SchedulerView( + latent_width=latent_tile_width, + latent_height=latent_tile_height, + ), ).to(model.load_device) latent_samples = validate_latent_samples( @@ -217,8 +215,10 @@ class MixtureOfDiffusersModelWrapper: self._plan = plan self._existing_wrapper = existing_wrapper - self._tile_weights_2d: torch.Tensor | None = None - self._semantic_tile_weights = SemanticTileWeightCache(plan.tiles) + self._tile_predictions = TilePredictionAccumulator( + plan, + diffusion_mode="mixture_of_diffusers", + ) def __call__( self, @@ -248,35 +248,11 @@ class MixtureOfDiffusersModelWrapper: "ControlNet in the first implementation." ) - output_buffer = torch.zeros_like(x) - weight_buffer = new_spatial_weight_buffer(x, self._plan) - input_batch_size = int(x.shape[0]) - - for batch in self._plan.batches: - tiled_args = self._make_tiled_args( - args=args, - tiles=batch, - input_batch_size=input_batch_size, - ) - tile_output = self._call_original(apply_model, tiled_args) - weights = self._weights_for(tile_output) - accumulation_weights = weights.to(dtype=weight_buffer.dtype) - semantic_weights = self._semantic_tile_weights.for_output(tile_output) - for index, tile in enumerate(batch): - tile_slice = spatial_tile_slicer(tile, x.ndim) - start = index * input_batch_size - end = start + input_batch_size - model_weight, accumulation_weight = ( - self._semantic_tile_weights.for_tile( - semantic_weights, - tile, - ) - ) - tile_weight = weights * model_weight - output_buffer[tile_slice] += tile_output[start:end] * tile_weight - weight_buffer[tile_slice] += accumulation_weights * accumulation_weight - - return output_buffer / weight_buffer.to(dtype=output_buffer.dtype) + return self._tile_predictions.predict( + args=args, + x=x, + evaluate=lambda tiled_args: self._call_original(apply_model, tiled_args), + ) def _call_original( self, @@ -292,41 +268,6 @@ class MixtureOfDiffusersModelWrapper: raise ValueError("Mixture of Diffusers conditioning must be a dict.") return apply_model(args["input"], args["timestep"], **conditioning) - def _make_tiled_args( - self, - *, - args: dict[str, Any], - tiles: Sequence[LatentTile], - input_batch_size: int, - ) -> dict[str, Any]: - """Create apply-model args for one tile batch.""" - - return make_tiled_model_args( - args=args, - tiles=tiles, - input_batch_size=input_batch_size, - latent_height=self._plan.latent_height, - latent_width=self._plan.latent_width, - ) - - def _weights_for(self, x: torch.Tensor) -> torch.Tensor: - """Return cached Gaussian tile weights for the active device and dtype.""" - - if ( - self._tile_weights_2d is None - or self._tile_weights_2d.device != x.device - or self._tile_weights_2d.dtype != x.dtype - ): - self._tile_weights_2d = gaussian_tile_weights( - self._plan.tile_width, - self._plan.tile_height, - device=x.device, - dtype=x.dtype, - ) - return self._tile_weights_2d.reshape( - (1,) * (x.ndim - 2) + (self._plan.tile_height, self._plan.tile_width) - ) - def _validate_supplied_plan( plan: TiledDiffusionPlan, diff --git a/simple_syrup/runtime/multidiffusion_sampling.py b/simple_syrup/runtime/multidiffusion_sampling.py index 5db98e7..5181b19 100644 --- a/simple_syrup/runtime/multidiffusion_sampling.py +++ b/simple_syrup/runtime/multidiffusion_sampling.py @@ -10,7 +10,6 @@ from __future__ import annotations -from collections.abc import Sequence from importlib import import_module from types import ModuleType from typing import Any, cast @@ -18,7 +17,6 @@ from typing import Any, cast import torch from ..domain.tiled_diffusion import ( - LatentTile, TiledDiffusionPlan, build_tiled_diffusion_plan, ) @@ -30,11 +28,8 @@ from .tiled_sampling import ( ApplyModel, Latent, ModelFunctionWrapper, - SemanticTileWeightCache, - make_tiled_model_args, - new_spatial_weight_buffer, + TilePredictionAccumulator, reject_unsupported_conditioning, - spatial_tile_slicer, validate_latent_samples, validate_sampling_controls, validate_tensor_shape, @@ -94,6 +89,10 @@ def sample_multidiffusion( sampler_name=sampler_name, steps=steps, denoise=denoise, + view=sampling_schedulers.SchedulerView( + latent_width=latent_tile_width, + latent_height=latent_tile_height, + ), ).to(model.load_device) latent_samples = validate_latent_samples( @@ -219,7 +218,10 @@ class MultiDiffusionModelWrapper: self._plan = plan self._existing_wrapper = existing_wrapper - self._semantic_tile_weights = SemanticTileWeightCache(plan.tiles) + self._tile_predictions = TilePredictionAccumulator( + plan, + diffusion_mode="multidiffusion", + ) def __call__( self, @@ -249,32 +251,11 @@ class MultiDiffusionModelWrapper: "ControlNet in the first implementation." ) - output_buffer = torch.zeros_like(x) - weight_buffer = new_spatial_weight_buffer(x, self._plan) - input_batch_size = int(x.shape[0]) - - for batch in self._plan.batches: - tiled_args = self._make_tiled_args( - args=args, - tiles=batch, - input_batch_size=input_batch_size, - ) - tile_output = self._call_original(apply_model, tiled_args) - tile_weights = self._semantic_tile_weights.for_output(tile_output) - for index, tile in enumerate(batch): - tile_slice = spatial_tile_slicer(tile, x.ndim) - start = index * input_batch_size - end = start + input_batch_size - model_weight, accumulation_weight = ( - self._semantic_tile_weights.for_tile( - tile_weights, - tile, - ) - ) - output_buffer[tile_slice] += tile_output[start:end] * model_weight - weight_buffer[tile_slice] += accumulation_weight - - return output_buffer / weight_buffer.to(dtype=output_buffer.dtype) + return self._tile_predictions.predict( + args=args, + x=x, + evaluate=lambda tiled_args: self._call_original(apply_model, tiled_args), + ) def _call_original( self, @@ -290,23 +271,6 @@ class MultiDiffusionModelWrapper: raise ValueError("MultiDiffusion conditioning must be a dict.") return apply_model(args["input"], args["timestep"], **conditioning) - def _make_tiled_args( - self, - *, - args: dict[str, Any], - tiles: Sequence[LatentTile], - input_batch_size: int, - ) -> dict[str, Any]: - """Create apply-model args for one tile batch.""" - - return make_tiled_model_args( - args=args, - tiles=tiles, - input_batch_size=input_batch_size, - latent_height=self._plan.latent_height, - latent_width=self._plan.latent_width, - ) - def _reject_unipc_sampler(sampler_name: str) -> None: """Reject UniPC samplers because MultiDiffusion is incompatible with them.""" diff --git a/simple_syrup/runtime/regional_multidiffusion_sampling.py b/simple_syrup/runtime/regional_multidiffusion_sampling.py index fdd0b8c..e06fa91 100644 --- a/simple_syrup/runtime/regional_multidiffusion_sampling.py +++ b/simple_syrup/runtime/regional_multidiffusion_sampling.py @@ -81,14 +81,6 @@ def sample_regional_multidiffusion( reject_unsupported_conditioning(region.positive, sampler_label=SAMPLER_LABEL) sampler = sampling_samplers.resolve_sampler(sampler_name) - sigmas = sampling_schedulers.calculate_sigmas( - model=model, - scheduler_name=scheduler, - sampler_name=sampler_name, - steps=steps, - denoise=denoise, - ).to(model.load_device) - latent_samples = validate_latent_samples( latent_image, sampler_label=SAMPLER_LABEL, @@ -102,6 +94,14 @@ def sample_regional_multidiffusion( latent_image.get("downscale_ratio_spacial", None), ) validate_tensor_shape(latent_samples, sampler_label=SAMPLER_LABEL) + sigmas = sampling_schedulers.calculate_sigmas( + model=model, + scheduler_name=scheduler, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + view=sampling_schedulers.SchedulerView.from_tensor(latent_samples), + ).to(model.load_device) latent_height = int(latent_samples.shape[-2]) latent_width = int(latent_samples.shape[-1]) sampling_model, summary = clone_model_with_regional_multidiffusion( diff --git a/simple_syrup/runtime/sampling_schedulers.py b/simple_syrup/runtime/sampling_schedulers.py index 2fe7539..56218ab 100644 --- a/simple_syrup/runtime/sampling_schedulers.py +++ b/simple_syrup/runtime/sampling_schedulers.py @@ -10,7 +10,8 @@ from __future__ import annotations import math -from collections.abc import Sequence +from collections.abc import Callable, Sequence +from dataclasses import dataclass from importlib import import_module from types import ModuleType from typing import Protocol, cast @@ -21,7 +22,14 @@ from ..shared.logging import get_logger LOGGER = get_logger(__name__) -EXTRA_SCHEDULERS = ("AYS SD1", "AYS SDXL", "GITS", "beta57", "automatic_a1111") +EXTRA_SCHEDULERS = ( + "AYS SD1", + "AYS SDXL", + "GITS", + "beta57", + "automatic_a1111", + "Flux2", +) GITS_DEFAULT_COEFF = 1.20 BETA57_ALPHA = 0.5 BETA57_BETA = 0.7 @@ -310,6 +318,33 @@ class SamplingModel(Protocol): """Return a named ComfyUI model object.""" +@dataclass(frozen=True) +class SchedulerView: + """Describe the spatial latent view evaluated by one model prediction.""" + + latent_width: int + latent_height: int + + def __post_init__(self) -> None: + """Reject dimensions that cannot define a spatial schedule.""" + + if self.latent_width <= 0 or self.latent_height <= 0: + raise ValueError("Scheduler model-view dimensions must be positive.") + + @classmethod + def from_tensor(cls, samples: torch.Tensor) -> SchedulerView: + """Create a scheduler view from the tensor's final spatial dimensions.""" + + if samples.ndim < 2: + raise ValueError( + "Scheduler model-view samples must have spatial dimensions." + ) + return cls( + latent_width=int(samples.shape[-1]), + latent_height=int(samples.shape[-2]), + ) + + def available_schedulers() -> tuple[str, ...]: """Return core ComfyUI schedulers plus locally resolved extra schedulers.""" @@ -324,6 +359,8 @@ def calculate_sigmas( sampler_name: str, steps: int, denoise: float, + *, + view: SchedulerView | None = None, ) -> torch.Tensor: """Calculate sigmas for a core or SimpleSyrup-owned scheduler.""" @@ -351,6 +388,7 @@ def calculate_sigmas( sampler_name=sampler_name, steps=steps, denoise=denoise, + view=view, ) return _calculate_core_sigmas( @@ -414,6 +452,7 @@ def _calculate_extra_sigmas( sampler_name: str, steps: int, denoise: float, + view: SchedulerView | None, ) -> torch.Tensor: """Calculate sigmas for locally resolved extra scheduler policies.""" @@ -425,7 +464,12 @@ def _calculate_extra_sigmas( calculation_steps = ( schedule_steps + 1 if discard_penultimate_sigma else schedule_steps ) - sigmas = _calculate_extra_schedule(model, scheduler_name, calculation_steps) + sigmas = _calculate_extra_schedule( + model, + scheduler_name, + calculation_steps, + view=view, + ) if discard_penultimate_sigma: sigmas = torch.cat([sigmas[:-2], sigmas[-1:]]) @@ -456,6 +500,8 @@ def _calculate_extra_schedule( model: SamplingModel, scheduler_name: str, steps: int, + *, + view: SchedulerView | None, ) -> torch.Tensor: """Calculate a full local extra scheduler output.""" @@ -469,9 +515,47 @@ def _calculate_extra_schedule( return _calculate_beta57_schedule(model, steps) if scheduler_name == "automatic_a1111": return _calculate_automatic_a1111_schedule(model, steps) + if scheduler_name == "Flux2": + return _calculate_flux2_schedule(model, steps, view=view) raise ValueError(f"Unsupported extra scheduler '{scheduler_name}'.") +def _calculate_flux2_schedule( + model: SamplingModel, + steps: int, + *, + view: SchedulerView | None, +) -> torch.Tensor: + """Calculate ComfyUI's Flux2 schedule for the effective model view.""" + + if view is None: + raise ValueError("Flux2 scheduler requires a model view resolution.") + latent_format = model.get_model_object("latent_format") + spatial_ratio = getattr(latent_format, "spacial_downscale_ratio", None) + if ( + isinstance(spatial_ratio, bool) + or not isinstance(spatial_ratio, (int, float)) + or spatial_ratio <= 0 + ): + raise ValueError( + "Flux2 scheduler requires the model latent format to expose a " + "positive spacial_downscale_ratio." + ) + + pixel_width = view.latent_width * float(spatial_ratio) + pixel_height = view.latent_height * float(spatial_ratio) + image_sequence_length = round(pixel_width * pixel_height / (16 * 16)) + flux_nodes = import_module("comfy_extras.nodes_flux") + get_schedule = cast( + Callable[[int, int], Sequence[float] | torch.Tensor], + flux_nodes.get_schedule, + ) + return torch.as_tensor( + get_schedule(steps, image_sequence_length), + dtype=torch.float32, + ).detach() + + def _calculate_ays_schedule(model_type: str, steps: int) -> torch.Tensor: """Calculate full AYS sigmas for the requested step count.""" diff --git a/simple_syrup/runtime/tiled_sampling.py b/simple_syrup/runtime/tiled_sampling.py index fde1bbf..b6d6a21 100644 --- a/simple_syrup/runtime/tiled_sampling.py +++ b/simple_syrup/runtime/tiled_sampling.py @@ -15,12 +15,20 @@ from dataclasses import dataclass from typing import Any, TypeAlias import torch +import torch.nn.functional as functional -from ..domain.tiled_diffusion import LatentTile, TiledDiffusionPlan +from ..domain.contextual_diffusion import SpatialContext +from ..domain.tiled_diffusion import ( + LatentTile, + TiledDiffusionPlan, + gaussian_tile_weights, + validate_tiled_diffusion_mode, +) Latent: TypeAlias = dict[str, Any] ApplyModel: TypeAlias = Callable[..., torch.Tensor] ModelFunctionWrapper: TypeAlias = Callable[[ApplyModel, dict[str, Any]], torch.Tensor] +TileEvaluator: TypeAlias = Callable[[dict[str, Any]], torch.Tensor] UNSUPPORTED_CONDITIONING_KEYS = frozenset({"area", "control", "gligen"}) @@ -91,6 +99,110 @@ class SemanticTileWeightCache: return weights.model[index], weights.accumulation[index] +class TileBlendWeightCache: + """Resolve authoritative overlap weights for either tiled diffusion policy.""" + + def __init__(self, plan: TiledDiffusionPlan, diffusion_mode: str) -> None: + """Create weight caches for one immutable tiled prediction plan.""" + + validate_tiled_diffusion_mode(diffusion_mode) + self._diffusion_mode = diffusion_mode + self._semantic_weights = SemanticTileWeightCache(plan.tiles) + self._gaussian_weights: dict[ + tuple[torch.device, torch.dtype, int, int, int], torch.Tensor + ] = {} + + def for_tile( + self, + output: torch.Tensor, + tile: LatentTile, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Return model and accumulation weights for one predicted tile.""" + + semantic_weights = self._semantic_weights.for_output(output) + model_weight, accumulation_weight = self._semantic_weights.for_tile( + semantic_weights, + tile, + ) + if self._diffusion_mode == "multidiffusion": + return model_weight, accumulation_weight + + gaussian_weight = self._gaussian_for(output, tile) + return ( + model_weight * gaussian_weight, + accumulation_weight * gaussian_weight.to(dtype=torch.float32), + ) + + def _gaussian_for( + self, + output: torch.Tensor, + tile: LatentTile, + ) -> torch.Tensor: + """Return cached Mixture of Diffusers weights for one tile shape.""" + + cache_key = ( + output.device, + output.dtype, + output.ndim, + tile.width, + tile.height, + ) + cached = self._gaussian_weights.get(cache_key) + if cached is not None: + return cached + weights = gaussian_tile_weights( + tile.width, + tile.height, + device=output.device, + dtype=output.dtype, + ).reshape((1,) * (output.ndim - 2) + (tile.height, tile.width)) + self._gaussian_weights[cache_key] = weights + return weights + + +class TilePredictionAccumulator: + """Evaluate tiled model views and combine them with one selected policy.""" + + def __init__(self, plan: TiledDiffusionPlan, *, diffusion_mode: str) -> None: + """Bind an immutable plan to its overlap weighting policy.""" + + self._plan = plan + self._blend_weights = TileBlendWeightCache(plan, diffusion_mode) + + def predict( + self, + *, + args: dict[str, Any], + x: torch.Tensor, + evaluate: TileEvaluator, + ) -> torch.Tensor: + """Return one canvas prediction accumulated from bounded model views.""" + + output_buffer = torch.zeros_like(x) + weight_buffer = new_spatial_weight_buffer(x, self._plan) + input_batch_size = int(x.shape[0]) + for batch in self._plan.batches: + tiled_args = make_tiled_model_args( + args=args, + tiles=batch, + input_batch_size=input_batch_size, + latent_height=self._plan.latent_height, + latent_width=self._plan.latent_width, + ) + tile_output = evaluate(tiled_args) + for index, tile in enumerate(batch): + tile_slice = spatial_tile_slicer(tile, x.ndim) + start = index * input_batch_size + end = start + input_batch_size + model_weight, accumulation_weight = self._blend_weights.for_tile( + tile_output, + tile, + ) + output_buffer[tile_slice] += tile_output[start:end] * model_weight + weight_buffer[tile_slice] += accumulation_weight + return output_buffer / weight_buffer.to(dtype=output_buffer.dtype) + + def validate_sampling_controls( *, steps: int, @@ -270,6 +382,191 @@ def make_tiled_model_args( return tiled_args +def make_spatial_context_model_args( + *, + args: dict[str, Any], + contexts: Sequence[SpatialContext], + input_batch_size: int, + latent_height: int, + latent_width: int, +) -> dict[str, Any]: + """Create apply-model arguments for equally shaped spatial contexts.""" + + if not contexts: + raise ValueError("Spatial model arguments require at least one context.") + target_shape = (contexts[0].context_height, contexts[0].context_width) + if any( + (context.context_height, context.context_width) != target_shape + for context in contexts + ): + raise ValueError("Batched spatial contexts must use one model context shape.") + x = args["input"] + timestep = args["timestep"] + conditioning = args.get("c", {}) + if not isinstance(x, torch.Tensor): + raise ValueError("contextual sampler model input must be a tensor.") + if not isinstance(timestep, torch.Tensor): + raise ValueError("contextual sampler timestep must be a tensor.") + if not isinstance(conditioning, dict): + raise ValueError("contextual sampler conditioning must be a dict.") + + context_x = torch.cat( + [ + resize_spatial_tensor( + x[spatial_context_slicer(context, x.ndim)], + height=context.context_height, + width=context.context_width, + mode="nearest-exact", + ) + for context in contexts + ], + dim=0, + ) + context_timestep = torch.cat([timestep] * len(contexts), dim=0) + context_conditioning = spatial_context_conditioning( + conditioning=conditioning, + contexts=contexts, + input_batch_size=input_batch_size, + latent_height=latent_height, + latent_width=latent_width, + context_timestep=context_timestep, + ) + context_args = args.copy() + context_args["input"] = context_x + context_args["timestep"] = context_timestep + context_args["c"] = context_conditioning + if "cond_or_uncond" in args: + context_args["cond_or_uncond"] = repeat_sequence( + args["cond_or_uncond"], + len(contexts), + ) + return context_args + + +def spatial_context_conditioning( + *, + conditioning: dict[str, Any], + contexts: Sequence[SpatialContext], + input_batch_size: int, + latent_height: int, + latent_width: int, + context_timestep: torch.Tensor, +) -> dict[str, Any]: + """Resize spatial conditioning alongside arbitrary latent contexts.""" + + transformed: dict[str, Any] = {} + for key, value in conditioning.items(): + if key == "transformer_options" and isinstance(value, dict): + transformed[key] = tile_transformer_options( + value, + tile_count=len(contexts), + tiled_timestep=context_timestep, + ) + continue + transformed[key] = spatial_context_value( + value, + contexts=contexts, + input_batch_size=input_batch_size, + latent_height=latent_height, + latent_width=latent_width, + ) + return transformed + + +def spatial_context_value( + value: Any, + *, + contexts: Sequence[SpatialContext], + input_batch_size: int, + latent_height: int, + latent_width: int, +) -> Any: + """Transform tensors nested inside one spatial-context conditioning value.""" + + if isinstance(value, torch.Tensor): + if value.ndim >= 4 and value.shape[-2:] == ( + latent_height, + latent_width, + ): + return torch.cat( + [ + resize_spatial_tensor( + value[spatial_context_slicer(context, value.ndim)], + height=context.context_height, + width=context.context_width, + mode="nearest-exact", + ) + for context in contexts + ], + dim=0, + ) + if value.ndim >= 1 and value.shape[0] == input_batch_size: + return torch.cat([value] * len(contexts), dim=0) + if value.ndim >= 1 and value.shape[0] == 1: + repeats = [input_batch_size * len(contexts)] + [1] * (value.ndim - 1) + return value.repeat(repeats) + return value + if isinstance(value, list): + return [ + spatial_context_value( + item, + contexts=contexts, + input_batch_size=input_batch_size, + latent_height=latent_height, + latent_width=latent_width, + ) + for item in value + ] + if isinstance(value, tuple): + return tuple( + spatial_context_value( + item, + contexts=contexts, + input_batch_size=input_batch_size, + latent_height=latent_height, + latent_width=latent_width, + ) + for item in value + ) + return value + + +def spatial_context_slicer( + context: SpatialContext, + tensor_ndim: int, +) -> tuple[slice, ...]: + """Return a slicer for one arbitrary spatial context rectangle.""" + + return ( + (slice(None),) * (tensor_ndim - 2) + + (slice(context.y, context.y + context.height),) + + (slice(context.x, context.x + context.width),) + ) + + +def resize_spatial_tensor( + tensor: torch.Tensor, + *, + height: int, + width: int, + mode: str, +) -> torch.Tensor: + """Resize only the final two axes of a 4D or singleton-depth 5D tensor.""" + + if tensor.shape[-2:] == (height, width): + return tensor + leading_shape = tensor.shape[:-2] + flattened = tensor.reshape(-1, 1, tensor.shape[-2], tensor.shape[-1]) + align_corners = False if mode in {"bilinear", "bicubic"} else None + resized = functional.interpolate( + flattened, + size=(height, width), + mode=mode, + align_corners=align_corners, + ) + return resized.reshape(*leading_shape, height, width) + + def tile_conditioning( *, conditioning: dict[str, Any], diff --git a/simple_syrup/services/contextual_diffusion_sampling_service.py b/simple_syrup/services/contextual_diffusion_sampling_service.py new file mode 100644 index 0000000..7f14550 --- /dev/null +++ b/simple_syrup/services/contextual_diffusion_sampling_service.py @@ -0,0 +1,177 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Application service for contextual diffusion latent sampling.""" + +from __future__ import annotations + +from typing import Any, TypeAlias + +import torch + +from ..domain.conditioning_batch import ConditioningBatch, select_conditioning +from ..domain.contextual_diffusion import ( + ContextualDiffusionControls, + build_contextual_diffusion_plan, +) +from ..domain.segs import NativeSegs, coerce_segs_group +from ..domain.tiled_diffusion import validate_tiled_diffusion_mode +from ..runtime.contextual_diffusion_sampling import sample_contextual_diffusion +from .sampling_batch import ( + combine_latent_outputs, + latent_batch_size, + single_item_latent, +) + +Latent: TypeAlias = dict[str, Any] + + +class ContextualDiffusionSamplingService: + """Plan and execute composition-preserving contextual diffusion.""" + + def sample( + self, + *, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + diffusion_mode: str, + latent_context_size: int, + latent_context_overlap: int, + latent_context_batch_size: int, + global_weight: float, + global_steps: int, + global_decay: float, + segs: object | None = None, + ) -> Latent: + """Sample a latent with global and bounded detail contexts.""" + + validate_tiled_diffusion_mode(diffusion_mode) + controls = ContextualDiffusionControls( + latent_context_size=latent_context_size, + latent_context_overlap=latent_context_overlap, + latent_context_batch_size=latent_context_batch_size, + global_weight=global_weight, + global_steps=global_steps, + global_decay=global_decay, + ) + controls.validate() + batch_size = latent_batch_size(latent_image) + segs_group = coerce_segs_group(segs) if segs is not None else () + if segs_group and len(segs_group) not in (1, batch_size): + raise ValueError( + "Contextual Diffusion requires one SEGS payload or one per latent " + f"batch item; received {len(segs_group)} for batch size {batch_size}." + ) + split_batch = ( + bool(segs_group) + or isinstance(positive, ConditioningBatch) + or isinstance(negative, ConditioningBatch) + ) + if not split_batch: + return self._sample_item( + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + denoise=denoise, + diffusion_mode=diffusion_mode, + controls=controls, + segs=None, + ) + + outputs: list[torch.Tensor] = [] + for index in range(batch_size): + item_output = self._sample_item( + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=( + select_conditioning(positive, index) + if isinstance(positive, ConditioningBatch) + else positive + ), + negative=( + select_conditioning(negative, index) + if isinstance(negative, ConditioningBatch) + else negative + ), + latent_image=single_item_latent(latent_image, index), + denoise=denoise, + diffusion_mode=diffusion_mode, + controls=controls, + segs=( + segs_group[0 if len(segs_group) == 1 else index] + if segs_group + else None + ), + ) + samples = item_output.get("samples") + if not isinstance(samples, torch.Tensor): + raise TypeError( + "Contextual Diffusion output samples must be a torch.Tensor." + ) + outputs.append(samples) + return combine_latent_outputs(latent_image, outputs) + + def _sample_item( + self, + *, + model: Any, + seed: int, + steps: int, + cfg: float, + sampler_name: str, + scheduler: str, + positive: Any, + negative: Any, + latent_image: Latent, + denoise: float, + diffusion_mode: str, + controls: ContextualDiffusionControls, + segs: NativeSegs | None, + ) -> Latent: + """Build one canvas plan and execute it through the runtime adapter.""" + + samples = latent_image.get("samples") + if not isinstance(samples, torch.Tensor): + raise TypeError( + "Contextual Diffusion latent samples must be a torch.Tensor." + ) + plan = build_contextual_diffusion_plan( + latent_width=int(samples.shape[-1]), + latent_height=int(samples.shape[-2]), + controls=controls, + segs=segs, + ) + return sample_contextual_diffusion( + model=model, + seed=seed, + steps=steps, + cfg=cfg, + sampler_name=sampler_name, + scheduler=scheduler, + positive=positive, + negative=negative, + latent_image=latent_image, + denoise=denoise, + diffusion_mode=diffusion_mode, + controls=controls, + plan=plan, + ) diff --git a/simple_syrup/services/ksampler_sampling_service.py b/simple_syrup/services/ksampler_sampling_service.py index f3fb921..86066b3 100644 --- a/simple_syrup/services/ksampler_sampling_service.py +++ b/simple_syrup/services/ksampler_sampling_service.py @@ -39,13 +39,6 @@ class KSamplerSamplingService: """Sample a latent with configured SimpleSyrup sampler extensions.""" sampler = sampling_samplers.resolve_sampler(sampler_name) - sigmas = sampling_schedulers.calculate_sigmas( - model=model, - scheduler_name=scheduler, - sampler_name=sampler_name, - steps=steps, - denoise=denoise, - ).to(model.load_device) latent_samples = latent_image["samples"] if not isinstance(latent_samples, torch.Tensor): raise TypeError("KSampler latent samples must be a torch.Tensor.") @@ -61,6 +54,14 @@ class KSamplerSamplingService: raise TypeError( "KSampler normalized latent samples must be a torch.Tensor." ) + sigmas = sampling_schedulers.calculate_sigmas( + model=model, + scheduler_name=scheduler, + sampler_name=sampler_name, + steps=steps, + denoise=denoise, + view=sampling_schedulers.SchedulerView.from_tensor(latent_samples), + ).to(model.load_device) noise = comfy_sample.prepare_noise( latent_samples, seed, diff --git a/simple_syrup/services/sampling_batch.py b/simple_syrup/services/sampling_batch.py new file mode 100644 index 0000000..0fbfbb8 --- /dev/null +++ b/simple_syrup/services/sampling_batch.py @@ -0,0 +1,56 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Share latent-batch adaptation across sampling application services.""" + +from __future__ import annotations + +from typing import Any, TypeAlias + +import torch + +Latent: TypeAlias = dict[str, Any] + + +def latent_batch_size(latent_image: Latent) -> int: + """Return the validated number of latent batch items.""" + + samples = latent_image.get("samples") + if not isinstance(samples, torch.Tensor): + raise TypeError("Sampler latent samples must be a torch.Tensor.") + if samples.ndim < 1: + raise ValueError("Sampler latent samples must include a batch axis.") + return int(samples.shape[0]) + + +def single_item_latent(latent_image: Latent, index: int) -> Latent: + """Return one latent batch item with aligned batch metadata and mask.""" + + samples = latent_image.get("samples") + if not isinstance(samples, torch.Tensor): + raise TypeError("Sampler latent samples must be a torch.Tensor.") + item = latent_image.copy() + item["samples"] = samples[index : index + 1] + if "batch_index" in item: + item["batch_index"] = [item["batch_index"][index]] + noise_mask = item.get("noise_mask") + if isinstance(noise_mask, torch.Tensor) and noise_mask.shape[0] == int( + samples.shape[0] + ): + item["noise_mask"] = noise_mask[index : index + 1] + return item + + +def combine_latent_outputs( + latent_image: Latent, + outputs: list[torch.Tensor], +) -> Latent: + """Return one latent dictionary containing ordered sampled batch outputs.""" + + if not outputs: + raise ValueError("Sampler batch execution produced no outputs.") + result = latent_image.copy() + result.pop("downscale_ratio_spacial", None) + result["samples"] = torch.cat(outputs, dim=0) + return result diff --git a/simple_syrup/services/tiled_diffusion_sampling_service.py b/simple_syrup/services/tiled_diffusion_sampling_service.py index a0fe716..e05e5d6 100644 --- a/simple_syrup/services/tiled_diffusion_sampling_service.py +++ b/simple_syrup/services/tiled_diffusion_sampling_service.py @@ -16,6 +16,7 @@ from ..domain.segs_tiled_diffusion import build_segs_guided_tiled_diffusion_plan from ..domain.tiled_diffusion import TiledDiffusionPlan, validate_tiled_diffusion_mode from ..runtime import mixture_of_diffusers_sampling, multidiffusion_sampling from ..runtime.detail_previews import DetailPreviewContext +from .sampling_batch import combine_latent_outputs, single_item_latent Latent = dict[str, Any] @@ -219,7 +220,7 @@ class TiledDiffusionSamplingService: outputs: list[torch.Tensor] = [] for index in range(batch_size): - item_latent = self._single_item_latent(latent_image, index) + item_latent = single_item_latent(latent_image, index) samples = item_latent["samples"] if not isinstance(samples, torch.Tensor): raise TypeError( @@ -266,10 +267,7 @@ class TiledDiffusionSamplingService: "Tiled diffusion output samples must be a torch.Tensor." ) outputs.append(output_samples) - result = latent_image.copy() - result.pop("downscale_ratio_spacial", None) - result["samples"] = torch.cat(outputs, dim=0) - return result + return combine_latent_outputs(latent_image, outputs) def _sample_conditioning_batch( self, @@ -301,7 +299,7 @@ class TiledDiffusionSamplingService: outputs: list[torch.Tensor] = [] for index in range(int(latent_samples.shape[0])): - item_latent = self._single_item_latent(latent_image, index) + item_latent = single_item_latent(latent_image, index) output = self.sample( diffusion_mode=diffusion_mode, model=model, @@ -329,42 +327,7 @@ class TiledDiffusionSamplingService: ) outputs.append(output_samples) - result = latent_image.copy() - result.pop("downscale_ratio_spacial", None) - result["samples"] = torch.cat(outputs, dim=0) - return result - - def _single_item_latent(self, latent_image: Latent, index: int) -> Latent: - """Return a latent dictionary for one batch item.""" - - latent_samples = latent_image["samples"] - if not isinstance(latent_samples, torch.Tensor): - raise TypeError("Tiled diffusion latent samples must be a torch.Tensor.") - item = latent_image.copy() - item["samples"] = latent_samples[index : index + 1] - if "batch_index" in item: - item["batch_index"] = [item["batch_index"][index]] - if "noise_mask" in item: - item["noise_mask"] = self._slice_noise_mask( - item["noise_mask"], - index, - latent_samples, - ) - return item - - def _slice_noise_mask( - self, - noise_mask: Any, - index: int, - latent_samples: torch.Tensor, - ) -> Any: - """Return the noise mask slice matching one latent batch item.""" - - if isinstance(noise_mask, torch.Tensor) and noise_mask.shape[0] == int( - latent_samples.shape[0], - ): - return noise_mask[index : index + 1] - return noise_mask + return combine_latent_outputs(latent_image, outputs) def _uses_conditioning_batch(self, positive: Any, negative: Any) -> bool: """Return whether tiled sampling needs per-item conditioning selection.""" diff --git a/tests/test_contextual_diffusion_domain.py b/tests/test_contextual_diffusion_domain.py new file mode 100644 index 0000000..16eeafa --- /dev/null +++ b/tests/test_contextual_diffusion_domain.py @@ -0,0 +1,98 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for deterministic contextual diffusion planning.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.domain.contextual_diffusion import ( + ContextualDiffusionControls, + build_contextual_diffusion_plan, + fit_context_shape, +) +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment + + +def test_global_context_fits_maximum_dimension_without_boxing() -> None: + """The whole canvas keeps its aspect ratio and uses no artificial padding.""" + + assert fit_context_shape(384, 256, 128) == (128, 86) + assert fit_context_shape(64, 96, 128) == (64, 96) + + +def test_connected_segs_replace_regular_grid_with_guided_tile_plan() -> None: + """Contextual Diffusion delegates its tile path to SEGS-guided planning.""" + + large = torch.ones((64, 64), dtype=torch.float32) + small = torch.zeros((64, 64), dtype=torch.float32) + small[24:40, 24:40] = 1.0 + plan = build_contextual_diffusion_plan( + latent_width=64, + latent_height=64, + controls=_controls(), + segs=((64, 64), (_segment(large, 0.8), _segment(small, 0.9))), + ) + + assert len(plan.tile_plan.tiles) > 1 + assert all(tile.weight_mask is not None for tile in plan.tile_plan.tiles) + + +def test_missing_segs_uses_regular_tiled_diffusion_plan() -> None: + """The optional SEGS input preserves ordinary tiled diffusion as fallback.""" + + plan = build_contextual_diffusion_plan( + latent_width=64, + latent_height=64, + controls=_controls(), + segs=None, + ) + + assert len(plan.tile_plan.tiles) > 1 + assert all(tile.weight_mask is None for tile in plan.tile_plan.tiles) + + +def test_invalid_overlap_fails_before_planning() -> None: + """An overlap that cannot advance a context is rejected explicitly.""" + + with pytest.raises(ValueError, match="latent_context_overlap"): + build_contextual_diffusion_plan( + latent_width=64, + latent_height=64, + controls=_controls(latent_context_overlap=32), + segs=None, + ) + + +def _controls( + *, + latent_context_overlap: int = 8, +) -> ContextualDiffusionControls: + """Return compact valid controls for planner tests.""" + + return ContextualDiffusionControls( + latent_context_size=32, + latent_context_overlap=latent_context_overlap, + latent_context_batch_size=2, + global_weight=1.0, + global_steps=1, + global_decay=0.5, + ) + + +def _segment(mask: torch.Tensor, confidence: float) -> Segment: + """Return a full-canvas SEG for a test mask.""" + + height, width = mask.shape + crop = CropRegion(0, 0, width, height) + return Segment( + cropped_image=None, + cropped_mask=mask, + confidence=confidence, + crop_region=crop, + bbox=BoundingBox(*crop), + label="region", + ) diff --git a/tests/test_contextual_diffusion_sampling.py b/tests/test_contextual_diffusion_sampling.py new file mode 100644 index 0000000..92291a0 --- /dev/null +++ b/tests/test_contextual_diffusion_sampling.py @@ -0,0 +1,289 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for contextual diffusion prediction fusion.""" + +from __future__ import annotations + +from typing import Any + +import pytest +import torch + +from simple_syrup.domain.contextual_diffusion import ( + ContextualDiffusionControls, + build_contextual_diffusion_plan, +) +from simple_syrup.runtime import ( + contextual_diffusion_sampling, + sampling_samplers, + sampling_schedulers, +) +from simple_syrup.runtime.contextual_diffusion_sampling import ( + ContextualDiffusionModelWrapper, +) + +comfy_sample = contextual_diffusion_sampling._comfy_sample() +comfy_utils = contextual_diffusion_sampling._comfy_utils() +latent_preview = contextual_diffusion_sampling._latent_preview() + + +def test_global_prediction_owns_low_frequency_while_tiles_keep_detail() -> None: + """The native view replaces tile-level scene intent without blurring detail.""" + + controls = _controls() + plan = build_contextual_diffusion_plan( + latent_width=32, + latent_height=16, + controls=controls, + segs=None, + ) + wrapper = ContextualDiffusionModelWrapper( + plan=plan, + controls=controls, + sigmas=torch.tensor([1.0, 0.0]), + existing_wrapper=None, + ) + calls: list[tuple[int, int]] = [] + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **conditioning: object, + ) -> torch.Tensor: + """Return high-frequency tile detail and a constant whole-image intent.""" + + del timestep, conditioning + calls.append((int(x.shape[-2]), int(x.shape[-1]))) + if x.shape[-2:] == (8, 16): + return torch.full_like(x, 3.0) + rows = torch.arange(x.shape[-2], device=x.device).reshape(1, 1, -1, 1) + return torch.where(rows % 2 == 0, 1.0, -1.0).expand_as(x) + + output = wrapper( + apply_model, + { + "input": torch.zeros((1, 1, 16, 32)), + "timestep": torch.tensor([1.0]), + "c": {}, + }, + ) + + assert calls == [(16, 16), (8, 16)] + assert torch.allclose(output[:, :, 0::2], torch.full((1, 1, 8, 32), 4.0)) + assert torch.allclose(output[:, :, 1::2], torch.full((1, 1, 8, 32), 2.0)) + + +def test_global_prediction_decays_then_stops_after_configured_steps() -> None: + """Global authority decays before late denoising becomes local-only.""" + + controls = _controls(global_steps=2) + plan = build_contextual_diffusion_plan( + latent_width=32, + latent_height=16, + controls=controls, + segs=None, + ) + wrapper = ContextualDiffusionModelWrapper( + plan=plan, + controls=controls, + sigmas=torch.tensor([1.0, 0.75, 0.5, 0.25, 0.0]), + existing_wrapper=None, + ) + calls: list[tuple[int, int]] = [] + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **conditioning: object, + ) -> torch.Tensor: + """Record whether each prediction uses local or global context.""" + + del timestep, conditioning + calls.append((int(x.shape[-2]), int(x.shape[-1]))) + value = 3.0 if x.shape[-2:] == (8, 16) else 1.0 + return torch.full_like(x, value) + + base_args = { + "input": torch.zeros((1, 1, 16, 32)), + "c": {}, + } + output = wrapper(apply_model, base_args | {"timestep": torch.tensor([0.75])}) + assert calls == [(16, 16), (8, 16)] + assert torch.allclose(output, torch.full_like(output, 2.0)) + + calls.clear() + output = wrapper(apply_model, base_args | {"timestep": torch.tensor([0.5])}) + assert calls == [(16, 16)] + assert torch.allclose(output, torch.ones_like(output)) + + +def test_native_sized_canvas_delegates_to_one_original_model_call() -> None: + """A canvas already inside the model view limit behaves like normal sampling.""" + + controls = _controls() + plan = build_contextual_diffusion_plan( + latent_width=16, + latent_height=16, + controls=controls, + segs=None, + ) + wrapper = ContextualDiffusionModelWrapper( + plan=plan, + controls=controls, + sigmas=torch.tensor([1.0, 0.0]), + existing_wrapper=None, + ) + calls = 0 + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **conditioning: object, + ) -> torch.Tensor: + """Count direct model evaluations.""" + + del timestep, conditioning + nonlocal calls + calls += 1 + return x + 2.0 + + x = torch.zeros((1, 1, 16, 16)) + output = wrapper( + apply_model, + {"input": x, "timestep": torch.tensor([1.0]), "c": {}}, + ) + + assert calls == 1 + assert torch.equal(output, x + 2.0) + + +def test_runtime_delegates_sampling_to_comfy_with_wrapped_clone( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The vertical runtime path preserves KSampler sampling and latent metadata.""" + + model = _FakeModel() + sampler = object() + latent_samples = torch.zeros((1, 4, 16, 32)) + sampled = torch.ones_like(latent_samples) + latent = { + "samples": latent_samples, + "downscale_ratio_spacial": 2, + "kept": "metadata", + } + controls = _controls() + plan = build_contextual_diffusion_plan( + latent_width=32, + latent_height=16, + controls=controls, + segs=None, + ) + calls: dict[str, Any] = {} + + monkeypatch.setattr( + sampling_samplers, + "resolve_sampler", + lambda _name: sampler, + ) + monkeypatch.setattr( + sampling_schedulers, + "calculate_sigmas", + lambda **_kwargs: torch.tensor([1.0, 0.0]), + ) + monkeypatch.setattr( + comfy_sample, + "fix_empty_latent_channels", + lambda _model, samples, _ratio: samples, + ) + monkeypatch.setattr( + comfy_sample, + "prepare_noise", + lambda samples, _seed, _batch_inds=None: torch.ones_like(samples), + ) + monkeypatch.setattr(latent_preview, "prepare_callback", lambda _model, _steps: None) + monkeypatch.setattr(comfy_utils, "PROGRESS_BAR_ENABLED", False) + + def fake_sample_custom( + sampling_model: _FakeModel, + noise: torch.Tensor, + cfg: float, + received_sampler: object, + sigmas: torch.Tensor, + positive: object, + negative: object, + latent_image: torch.Tensor, + **kwargs: object, + ) -> torch.Tensor: + """Capture the final Comfy boundary call.""" + + del noise, cfg, sigmas, positive, negative, latent_image, kwargs + calls["model"] = sampling_model + calls["sampler"] = received_sampler + return sampled + + monkeypatch.setattr(comfy_sample, "sample_custom", fake_sample_custom) + + output = contextual_diffusion_sampling.sample_contextual_diffusion( + model=model, + seed=7, + steps=2, + cfg=1.0, + sampler_name="euler", + scheduler="simple", + positive=[], + negative=[], + latent_image=latent, + denoise=0.8, + diffusion_mode="mixture_of_diffusers", + controls=controls, + plan=plan, + ) + + assert calls["model"] is not model + assert isinstance(calls["model"].wrapper, ContextualDiffusionModelWrapper) + assert calls["model"].wrapper.diffusion_mode == "mixture_of_diffusers" + assert calls["sampler"] is sampler + assert output["samples"] is sampled + assert output["kept"] == "metadata" + assert "downscale_ratio_spacial" not in output + + +def _controls( + *, + global_steps: int = 1, + global_decay: float = 0.5, +) -> ContextualDiffusionControls: + """Return a small two-tile test configuration.""" + + return ContextualDiffusionControls( + latent_context_size=16, + latent_context_overlap=0, + latent_context_batch_size=2, + global_weight=1.0, + global_steps=global_steps, + global_decay=global_decay, + ) + + +class _FakeModel: + """Provide the ModelPatcher surface used by the semantic runtime.""" + + def __init__(self, model_options: dict[str, Any] | None = None) -> None: + """Create a CPU-backed fake model patcher.""" + + self.load_device = torch.device("cpu") + self.model_options = {} if model_options is None else model_options + self.wrapper: object | None = None + + def clone(self) -> _FakeModel: + """Return a clone with copied model options.""" + + return _FakeModel(self.model_options.copy()) + + def set_model_unet_function_wrapper(self, wrapper: object) -> None: + """Capture the installed wrapper.""" + + self.wrapper = wrapper + self.model_options["model_function_wrapper"] = wrapper diff --git a/tests/test_contextual_diffusion_sampling_service.py b/tests/test_contextual_diffusion_sampling_service.py new file mode 100644 index 0000000..a7522ec --- /dev/null +++ b/tests/test_contextual_diffusion_sampling_service.py @@ -0,0 +1,178 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for contextual diffusion sampling orchestration.""" + +from __future__ import annotations + +from typing import Any + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment +from simple_syrup.services import ( + contextual_diffusion_sampling_service as service_module, +) +from simple_syrup.services.contextual_diffusion_sampling_service import ( + ContextualDiffusionSamplingService, +) + + +def test_service_builds_global_context_and_segs_guided_tile_plan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Connected SEGS reach the runtime through the existing guided tile planner.""" + + calls: list[dict[str, Any]] = [] + + def fake_sample_contextual_diffusion(**kwargs: Any) -> dict[str, Any]: + """Record the completed plan and return the input latent.""" + + calls.append(kwargs) + latent_image = kwargs["latent_image"] + if not isinstance(latent_image, dict): + raise TypeError("Test runtime expected a latent dictionary.") + return latent_image + + monkeypatch.setattr( + service_module, + "sample_contextual_diffusion", + fake_sample_contextual_diffusion, + ) + latent = {"samples": torch.zeros((1, 4, 64, 96))} + + result = ContextualDiffusionSamplingService().sample( + **_sample_kwargs(latent=latent, segs=_segs(512, 768)) + ) + + assert torch.equal(result["samples"], latent["samples"]) + assert len(calls) == 1 + assert calls[0]["diffusion_mode"] == "mixture_of_diffusers" + plan = calls[0]["plan"] + assert ( + plan.global_context.context_width, + plan.global_context.context_height, + ) == (64, 44) + assert len(plan.tile_plan.tiles) > 1 + assert all(tile.weight_mask is not None for tile in plan.tile_plan.tiles) + + +def test_service_rejects_segs_batch_mismatch_before_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ambiguous per-image semantic guidance fails before model sampling.""" + + called = False + + def fake_sample_contextual_diffusion(**kwargs: Any) -> dict[str, Any]: + """Mark unexpected runtime entry.""" + + del kwargs + nonlocal called + called = True + return {} + + monkeypatch.setattr( + service_module, + "sample_contextual_diffusion", + fake_sample_contextual_diffusion, + ) + latent = {"samples": torch.zeros((2, 4, 64, 96))} + + with pytest.raises(ValueError, match="one SEGS payload or one per latent"): + ContextualDiffusionSamplingService().sample( + **_sample_kwargs( + latent=latent, + segs=[_segs(512, 768), _segs(512, 768), _segs(512, 768)], + ) + ) + + assert not called + + +def test_service_rejects_invalid_controls_before_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Invalid context geometry fails without sampler side effects.""" + + monkeypatch.setattr( + service_module, + "sample_contextual_diffusion", + lambda **_kwargs: pytest.fail("runtime should not be called"), + ) + + with pytest.raises(ValueError, match="latent_context_overlap"): + ContextualDiffusionSamplingService().sample( + **( + _sample_kwargs( + latent={"samples": torch.zeros((1, 4, 64, 96))}, + segs=None, + ) + | {"latent_context_overlap": 64} + ) + ) + + +def test_service_rejects_unknown_diffusion_mode_before_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unknown tile blending policies fail before model sampling begins.""" + + monkeypatch.setattr( + service_module, + "sample_contextual_diffusion", + lambda **_kwargs: pytest.fail("runtime should not be called"), + ) + + with pytest.raises(ValueError, match="diffusion_mode must be one of"): + ContextualDiffusionSamplingService().sample( + **( + _sample_kwargs( + latent={"samples": torch.zeros((1, 4, 64, 96))}, + segs=None, + ) + | {"diffusion_mode": "unknown"} + ) + ) + + +def _sample_kwargs(*, latent: dict[str, Any], segs: object | None) -> dict[str, Any]: + """Return one valid service request.""" + + return { + "model": object(), + "seed": 1, + "steps": 4, + "cfg": 1.0, + "sampler_name": "euler", + "scheduler": "simple", + "positive": [], + "negative": [], + "latent_image": latent, + "denoise": 0.5, + "diffusion_mode": "mixture_of_diffusers", + "latent_context_size": 64, + "latent_context_overlap": 8, + "latent_context_batch_size": 2, + "global_weight": 1.0, + "global_steps": 1, + "global_decay": 0.5, + "segs": segs, + } + + +def _segs(height: int, width: int) -> object: + """Return one full-image Impact-compatible SEG payload.""" + + crop = CropRegion(0, 0, width, height) + segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((height, width), dtype=torch.float32), + confidence=1.0, + crop_region=crop, + bbox=BoundingBox(*crop), + label="subject", + ) + return ((height, width), (segment,)) diff --git a/tests/test_ksampler_contextual_diffusion_node.py b/tests/test_ksampler_contextual_diffusion_node.py new file mode 100644 index 0000000..944945b --- /dev/null +++ b/tests/test_ksampler_contextual_diffusion_node.py @@ -0,0 +1,152 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the KSampler Contextual Diffusion node contract.""" + +from __future__ import annotations + +from typing import Any + +import pytest +import torch + +from simple_syrup.nodes.ksampler_contextual_diffusion import ( + KSamplerContextualDiffusion, +) +from simple_syrup.runtime import sampling_samplers, sampling_schedulers + + +def test_input_types_expose_concise_klein_oriented_controls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The node keeps KSampler inputs and bounded contextual settings.""" + + monkeypatch.setattr(sampling_samplers, "available_samplers", lambda: ("euler",)) + monkeypatch.setattr( + sampling_schedulers, + "available_schedulers", + lambda: ("simple",), + ) + + declared = KSamplerContextualDiffusion.INPUT_TYPES() + required = declared["required"] + + assert tuple(required) == ( + "model", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "positive", + "negative", + "latent_image", + "denoise", + "diffusion_mode", + "latent_context_size", + "latent_context_overlap", + "latent_context_batch_size", + "global_weight", + "global_steps", + "global_decay", + ) + assert required["steps"][1]["default"] == 4 + assert required["cfg"][1]["default"] == 1.0 + assert required["diffusion_mode"][0] == [ + "multidiffusion", + "mixture_of_diffusers", + ] + assert required["diffusion_mode"][1]["default"] == "multidiffusion" + assert required["latent_context_size"][1]["default"] == 96 + assert required["latent_context_overlap"][1]["default"] == 32 + assert required["latent_context_batch_size"][1]["default"] == 4 + assert required["global_weight"][1]["default"] == 1.0 + assert required["global_steps"][1]["default"] == 1 + assert required["global_decay"][1]["default"] == 0.5 + assert declared["optional"]["segs"][0] == "SEGS" + + +def test_node_metadata_matches_separate_sampler_contract() -> None: + """Contextual Diffusion remains a distinct sampler with latent output.""" + + assert KSamplerContextualDiffusion.RETURN_TYPES == ("LATENT",) + assert KSamplerContextualDiffusion.FUNCTION == "sample" + assert KSamplerContextualDiffusion.CATEGORY == "SimpleSyrup/Sampling" + assert "composition" in KSamplerContextualDiffusion.DESCRIPTION + + +def test_sample_delegates_every_control_to_service( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The node API owns no contextual planning or runtime behavior.""" + + fake_service = _FakeContextualDiffusionService() + monkeypatch.setattr( + KSamplerContextualDiffusion, + "service_class", + staticmethod(lambda: fake_service), + ) + latent = {"samples": torch.zeros((1, 4, 32, 48))} + segs = object() + + (result,) = KSamplerContextualDiffusion().sample( + model="model", + seed=12, + steps=8, + cfg=1.0, + sampler_name="euler", + scheduler="simple", + positive="positive", + negative="negative", + latent_image=latent, + denoise=0.7, + diffusion_mode="mixture_of_diffusers", + latent_context_size=96, + latent_context_overlap=12, + latent_context_batch_size=3, + global_weight=0.9, + global_steps=2, + global_decay=0.4, + segs=segs, + ) + + assert result is fake_service.output + assert fake_service.calls == [ + { + "model": "model", + "seed": 12, + "steps": 8, + "cfg": 1.0, + "sampler_name": "euler", + "scheduler": "simple", + "positive": "positive", + "negative": "negative", + "latent_image": latent, + "denoise": 0.7, + "diffusion_mode": "mixture_of_diffusers", + "latent_context_size": 96, + "latent_context_overlap": 12, + "latent_context_batch_size": 3, + "global_weight": 0.9, + "global_steps": 2, + "global_decay": 0.4, + "segs": segs, + } + ] + + +class _FakeContextualDiffusionService: + """Record node delegation without entering the Comfy runtime.""" + + def __init__(self) -> None: + """Create a stable output and empty call history.""" + + self.output: dict[str, Any] = {"samples": torch.ones((1, 4, 32, 48))} + self.calls: list[dict[str, Any]] = [] + + def sample(self, **kwargs: Any) -> dict[str, Any]: + """Record one call and return the stable latent.""" + + self.calls.append(kwargs) + return self.output diff --git a/tests/test_ksampler_extras_node.py b/tests/test_ksampler_extras_node.py index dd65dee..e33f2c6 100644 --- a/tests/test_ksampler_extras_node.py +++ b/tests/test_ksampler_extras_node.py @@ -111,6 +111,7 @@ def test_scheduler_options_include_extras_and_exclude_svd() -> None: assert "GITS" in scheduler_options assert "beta57" in scheduler_options assert "automatic_a1111" in scheduler_options + assert "Flux2" in scheduler_options assert "AYS SVD" not in scheduler_options @@ -152,6 +153,8 @@ def test_sample_delegates_to_runtime_helpers( sampler_name: str, steps: int, denoise: float, + *, + view: sampling_schedulers.SchedulerView, ) -> torch.Tensor: """Record scheduler calculation.""" @@ -161,6 +164,7 @@ def test_sample_delegates_to_runtime_helpers( "sampler_name": sampler_name, "steps": steps, "denoise": denoise, + "view": view, } return fixed_sigmas @@ -286,6 +290,10 @@ def test_sample_delegates_to_runtime_helpers( "sampler_name": "lcm", "steps": 2, "denoise": 0.8, + "view": sampling_schedulers.SchedulerView( + latent_width=8, + latent_height=8, + ), } assert calls["prepare_noise"]["batch_inds"] == [0] assert calls["sample_custom"]["noise_mask"] is latent_image["noise_mask"] diff --git a/tests/test_mixture_of_diffusers_sampling.py b/tests/test_mixture_of_diffusers_sampling.py index 19c8c60..d452eeb 100644 --- a/tests/test_mixture_of_diffusers_sampling.py +++ b/tests/test_mixture_of_diffusers_sampling.py @@ -533,6 +533,9 @@ def test_sample_delegates_to_comfy_sampling_with_cloned_wrapped_model( assert calls["sample_custom"]["sampler"] is sampler assert calls["sample_custom"]["sigmas"] is fixed_sigmas assert calls["sample_custom"]["disable_pbar"] is True + assert calls["calculate_sigmas"]["view"] == ( + sampling_schedulers.SchedulerView(latent_width=4, latent_height=4) + ) def test_sample_accepts_singleton_depth_5d_latent( diff --git a/tests/test_multidiffusion_sampling.py b/tests/test_multidiffusion_sampling.py index 8f6ac87..194b219 100644 --- a/tests/test_multidiffusion_sampling.py +++ b/tests/test_multidiffusion_sampling.py @@ -678,6 +678,9 @@ def test_sample_delegates_to_comfy_sampling_with_cloned_wrapped_model( assert calls["sample_custom"]["sampler"] is sampler assert calls["sample_custom"]["noise"] is fixed_noise assert calls["sample_custom"]["disable_pbar"] is True + assert calls["calculate_sigmas"]["view"] == ( + sampling_schedulers.SchedulerView(latent_width=4, latent_height=4) + ) def test_sample_accepts_singleton_depth_5d_latent( diff --git a/tests/test_persisted_widget_order_contract.py b/tests/test_persisted_widget_order_contract.py index c748c32..05a52c4 100644 --- a/tests/test_persisted_widget_order_contract.py +++ b/tests/test_persisted_widget_order_contract.py @@ -19,6 +19,9 @@ from simple_syrup.nodes.detail_segs_by_scale_factor_tiled_diffusion import ( from simple_syrup.nodes.detect_segs_with_ultralytics import DetectSEGSWithUltralytics from simple_syrup.nodes.grounding_dino_model_loader import GroundingDINOModelLoader from simple_syrup.nodes.image_resize_to_target import ResizeImageToTarget +from simple_syrup.nodes.ksampler_contextual_diffusion import ( + KSamplerContextualDiffusion, +) from simple_syrup.nodes.ksampler_extras import KSamplerExtras from simple_syrup.nodes.ksampler_tiled_diffusion import KSamplerTiledDiffusion from simple_syrup.nodes.load_ultralytics_model import LoadUltralyticsModel @@ -146,6 +149,24 @@ _PERSISTED_WIDGET_PREFIXES: tuple[tuple[type[_ClassicNode], tuple[str, ...]], .. "latent_tile_batch_size", ), ), + ( + KSamplerContextualDiffusion, + ( + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + "diffusion_mode", + "latent_context_size", + "latent_context_overlap", + "latent_context_batch_size", + "global_weight", + "global_steps", + "global_decay", + ), + ), (LoadUltralyticsModel, ("model_name",)), (PromptEncodeStyle, ("encode_style",)), ( diff --git a/tests/test_registration.py b/tests/test_registration.py index a127c4f..c3b842b 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -33,6 +33,7 @@ BASE_NODE_IDS = [ "SimpleSyrup.KSamplerExtras", "SimpleSyrup.KSamplerPromptByRegion", "SimpleSyrup.KSamplerPromptByTiledRegion", + "SimpleSyrup.KSamplerContextualDiffusion", "SimpleSyrup.KSamplerTiledDiffusion", "SimpleSyrup.LatentDiagnostics", "SimpleSyrup.LayerStyleSAMModelsAdapter", diff --git a/tests/test_sampling_schedulers.py b/tests/test_sampling_schedulers.py index 37d8d4b..6eb4fd6 100644 --- a/tests/test_sampling_schedulers.py +++ b/tests/test_sampling_schedulers.py @@ -8,6 +8,7 @@ from __future__ import annotations import math from collections.abc import Sequence +from importlib import import_module from typing import Any import comfy.samplers @@ -16,6 +17,7 @@ import torch from simple_syrup.runtime import sampling_schedulers from simple_syrup.runtime.sampling_schedulers import ( + SchedulerView, available_schedulers, calculate_sigmas, ) @@ -36,6 +38,32 @@ class FakeModel: return self.model_sampling +class FakeLatentFormat: + """Expose the spatial compression used to recover image dimensions.""" + + def __init__(self, spacial_downscale_ratio: int) -> None: + """Store one deterministic latent-to-image scale.""" + + self.spacial_downscale_ratio = spacial_downscale_ratio + + +class FakeModelWithLatentFormat(FakeModel): + """Provide model-sampling and latent-format objects for Flux2 tests.""" + + def __init__(self, spacial_downscale_ratio: int) -> None: + """Create a fake model with the requested spatial compression.""" + + super().__init__() + self.latent_format = FakeLatentFormat(spacial_downscale_ratio) + + def get_model_object(self, name: str) -> object: + """Return the requested fake model object.""" + + if name == "latent_format": + return self.latent_format + return super().get_model_object(name) + + class FakeDiscreteModelSampling: """Provide k-diffusion-style discrete sigma conversion for tests.""" @@ -199,15 +227,59 @@ def test_available_schedulers_includes_core_and_extras() -> None: for scheduler in comfy.samplers.KSampler.SCHEDULERS: assert scheduler in schedulers - assert schedulers[-5:] == ( + assert schedulers[-6:] == ( "AYS SD1", "AYS SDXL", "GITS", "beta57", "automatic_a1111", + "Flux2", ) +def test_flux2_schedule_matches_comfy_for_model_view_resolution() -> None: + """Flux2 delegates to ComfyUI using the effective model-view resolution.""" + + model = FakeModelWithLatentFormat(spacial_downscale_ratio=16) + sigmas = calculate_sigmas( + model, + "Flux2", + "euler", + 4, + 1.0, + view=SchedulerView(latent_width=64, latent_height=64), + ) + + flux_nodes = import_module("comfy_extras.nodes_flux") + expected = torch.as_tensor(flux_nodes.get_schedule(4, 4096), dtype=torch.float32) + assert torch.allclose(sigmas, expected, atol=1e-6, rtol=1e-6) + + +def test_flux2_schedule_requires_a_model_view() -> None: + """Flux2 fails clearly when a caller omits its resolution context.""" + + with pytest.raises(ValueError, match="requires a model view"): + calculate_sigmas(FakeModel(), "Flux2", "euler", 4, 1.0) + + +def test_flux2_schedule_uses_model_latent_downscale_ratio() -> None: + """Flux2 remains selectable for models with non-Flux latent formats.""" + + model = FakeModelWithLatentFormat(spacial_downscale_ratio=8) + sigmas = calculate_sigmas( + model, + "Flux2", + "euler", + 4, + 1.0, + view=SchedulerView(latent_width=128, latent_height=128), + ) + + flux_nodes = import_module("comfy_extras.nodes_flux") + expected = torch.as_tensor(flux_nodes.get_schedule(4, 4096), dtype=torch.float32) + assert torch.allclose(sigmas, expected, atol=1e-6, rtol=1e-6) + + def test_available_schedulers_deduplicates_beta57_when_globally_patched( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_segs_tiled_diffusion.py b/tests/test_segs_tiled_diffusion.py index ca35e3f..d9c5081 100644 --- a/tests/test_segs_tiled_diffusion.py +++ b/tests/test_segs_tiled_diffusion.py @@ -11,8 +11,9 @@ import torch from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment from simple_syrup.domain.segs_tiled_diffusion import ( - _segment_mask_to_latent, build_segs_guided_tiled_diffusion_plan, + segment_mask_to_latent, + segment_weight_to_latent, ) @@ -127,7 +128,7 @@ def test_crop_local_mask_projects_directly_to_latent_space() -> None: label="small_region", ) - latent_mask = _segment_mask_to_latent( + latent_mask = segment_mask_to_latent( segment, source_height=4096, source_width=4096, @@ -141,6 +142,30 @@ def test_crop_local_mask_projects_directly_to_latent_space() -> None: assert not bool(latent_mask[:, :64].any()) +def test_crop_local_weight_projection_preserves_soft_mask_values() -> None: + """Semantic consumers can retain fractional SAM write ownership.""" + + crop = CropRegion(0, 0, 8, 8) + segment = Segment( + cropped_image=None, + cropped_mask=torch.full((8, 8), 0.25, dtype=torch.float32), + confidence=1.0, + crop_region=crop, + bbox=BoundingBox(*crop), + label="soft_region", + ) + + latent_weight = segment_weight_to_latent( + segment, + source_height=8, + source_width=8, + latent_width=8, + latent_height=8, + ) + + assert torch.allclose(latent_weight, torch.full((8, 8), 0.25)) + + def test_guided_plan_rejects_mismatched_image_aspect_ratio() -> None: """SEGS from a different image fail before tiled sampling begins.""" diff --git a/tests/test_tiled_sampling_runtime.py b/tests/test_tiled_sampling_runtime.py index 36fbe78..dde1ef5 100644 --- a/tests/test_tiled_sampling_runtime.py +++ b/tests/test_tiled_sampling_runtime.py @@ -9,6 +9,7 @@ from __future__ import annotations import pytest import torch +from simple_syrup.domain.contextual_diffusion import SpatialContext from simple_syrup.domain.tiled_diffusion import LatentTile, build_tiled_diffusion_plan from simple_syrup.runtime import tiled_sampling @@ -170,6 +171,57 @@ def test_tile_transformer_options_repeats_model_metadata() -> None: assert torch.equal(tiled["sample_sigmas"], torch.tensor([1.0, 0.0])) +def test_spatial_context_args_resize_latent_and_canvas_conditioning() -> None: + """A global context resizes spatial tensors and repeats aligned metadata.""" + + x = torch.arange(2 * 1 * 8 * 12, dtype=torch.float32).reshape((2, 1, 8, 12)) + timestep = torch.tensor([0.5, 0.75]) + context = SpatialContext(0, 0, 12, 8, 6, 4) + + transformed = tiled_sampling.make_spatial_context_model_args( + args={ + "input": x, + "timestep": timestep, + "cond_or_uncond": [0, 1], + "c": { + "c_concat": x.clone(), + "c_crossattn": torch.ones((2, 3, 1)), + "transformer_options": {"cond_or_uncond": [0, 1]}, + }, + }, + contexts=(context,), + input_batch_size=2, + latent_height=8, + latent_width=12, + ) + + assert transformed["input"].shape == (2, 1, 4, 6) + assert transformed["c"]["c_concat"].shape == (2, 1, 4, 6) + assert transformed["c"]["c_crossattn"].shape == (2, 3, 1) + assert transformed["c"]["transformer_options"]["cond_or_uncond"] == [0, 1] + + +def test_spatial_context_args_batch_equal_shapes_for_5d_latents() -> None: + """Contexts batch across the spatial axes of singleton-depth latents.""" + + x = torch.zeros((1, 16, 1, 8, 16)) + contexts = ( + SpatialContext(0, 0, 8, 8, 8, 8), + SpatialContext(8, 0, 8, 8, 8, 8), + ) + + transformed = tiled_sampling.make_spatial_context_model_args( + args={"input": x, "timestep": torch.tensor([1.0]), "c": {}}, + contexts=contexts, + input_batch_size=1, + latent_height=8, + latent_width=16, + ) + + assert transformed["input"].shape == (2, 16, 1, 8, 8) + assert transformed["timestep"].shape == (2,) + + def test_new_spatial_weight_buffer_broadcasts_over_spatial_axes() -> None: """Spatial weight buffers broadcast over BCHW and BCDHW model outputs.""" @@ -209,6 +261,37 @@ def test_semantic_tile_weight_cache_reuses_resident_weights() -> None: assert accumulation_weight.dtype == torch.float32 +def test_tile_prediction_accumulator_selects_multidiffusion_or_mod_weights() -> None: + """One shared accumulator preserves the distinct overlap blending policies.""" + + plan = build_tiled_diffusion_plan(6, 4, 4, 4, 2, 2) + x = torch.arange(6, dtype=torch.float32).reshape(1, 1, 1, 6).expand(1, 1, 4, 6) + args = {"input": x, "timestep": torch.tensor([1.0]), "c": {}} + + def evaluate(tiled_args: dict[str, object]) -> torch.Tensor: + """Return a distinct constant prediction for each source tile.""" + + tiled_input = tiled_args["input"] + if not isinstance(tiled_input, torch.Tensor): + raise TypeError("Test evaluator expected a tiled input tensor.") + means = tiled_input.mean(dim=(-2, -1), keepdim=True) + return means.expand_as(tiled_input) + + multidiffusion = tiled_sampling.TilePredictionAccumulator( + plan, + diffusion_mode="multidiffusion", + ).predict(args=args, x=x, evaluate=evaluate) + mixture = tiled_sampling.TilePredictionAccumulator( + plan, + diffusion_mode="mixture_of_diffusers", + ).predict(args=args, x=x, evaluate=evaluate) + + assert torch.allclose(multidiffusion[:, :, :, 2:4], torch.full((1, 1, 4, 2), 2.5)) + assert not torch.allclose(mixture[:, :, :, 2:4], multidiffusion[:, :, :, 2:4]) + assert torch.allclose(mixture[:, :, :, :2], multidiffusion[:, :, :, :2]) + assert torch.allclose(mixture[:, :, :, 4:], multidiffusion[:, :, :, 4:]) + + def test_contains_unsupported_conditioning_key_finds_nested_values() -> None: """Unsupported regional and control keys are detected recursively.""" From 4241d7e97c9148687398b0e5d6f18e3a527b640a Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sun, 2 Aug 2026 01:15:47 -0400 Subject: [PATCH 27/40] feat(segmentation): add SAM region overlay --- simple_syrup/nodes/segs_from_sam_output.py | 34 +++-- .../runtime/sam_region_overlay_renderer.py | 142 ++++++++++++++++++ .../services/segs_from_sam_output_service.py | 32 +++- tests/test_sam_region_overlay_renderer.py | 63 ++++++++ tests/test_segs_from_sam_output_node.py | 26 +++- tests/test_segs_from_sam_output_service.py | 24 +-- 6 files changed, 286 insertions(+), 35 deletions(-) create mode 100644 simple_syrup/runtime/sam_region_overlay_renderer.py create mode 100644 tests/test_sam_region_overlay_renderer.py diff --git a/simple_syrup/nodes/segs_from_sam_output.py b/simple_syrup/nodes/segs_from_sam_output.py index c6d1e80..2ec3e19 100644 --- a/simple_syrup/nodes/segs_from_sam_output.py +++ b/simple_syrup/nodes/segs_from_sam_output.py @@ -9,6 +9,8 @@ from __future__ import annotations from collections.abc import Callable from typing import Any, ClassVar +import torch + from ..masking.segs_mask_ops import iter_single_images, validate_image_batch from ..runtime.progress import PhaseProgressReporter, create_comfy_phase_progress from ..services.segs_from_sam_output_service import SEGSFromSAMOutputService @@ -22,11 +24,12 @@ class SEGSFromSAMOutput: create_comfy_phase_progress ) - RETURN_TYPES = ("SEGS",) - RETURN_NAMES = ("segs",) - OUTPUT_IS_LIST = (True,) + RETURN_TYPES = ("SEGS", "IMAGE") + RETURN_NAMES = ("segs", "overlay") + OUTPUT_IS_LIST = (True, False) OUTPUT_TOOLTIPS = ( "Automatic image regions as SEGS for detailing, masking, or tiled diffusion.", + "Source images with retained SAM regions shown as translucent colors.", ) FUNCTION = "generate" CATEGORY = "SimpleSyrup/Detection" @@ -82,33 +85,34 @@ class SEGSFromSAMOutput: sam_model: object, segmentation_resolution: int = 640, minimum_region_area: int = 0, - ) -> tuple[list[object]]: - """Return one automatic SEGS payload for each image batch item.""" + ) -> tuple[list[object], torch.Tensor]: + """Return aligned automatic SEGS and a SAM-style overlay image batch.""" image_batch = validate_image_batch(image, "SEGS from SAM Output") service = self.service_class() phase_progress = type(self).progress_factory( operation="segs_from_sam_output", subject=_sam_model_subject(sam_model), - total_phases=int(image_batch.shape[0]) * 3 + 1, + total_phases=int(image_batch.shape[0]) * 4 + 1, ) outputs: list[object] = [] + overlays: list[torch.Tensor] = [] try: for single_image in iter_single_images(image_batch): - outputs.append( - service.build( - image=single_image, - sam_model=sam_model, - segmentation_resolution=segmentation_resolution, - minimum_region_area=minimum_region_area, - phase_progress=phase_progress, - ) + result = service.build( + image=single_image, + sam_model=sam_model, + segmentation_resolution=segmentation_resolution, + minimum_region_area=minimum_region_area, + phase_progress=phase_progress, ) + outputs.append(result.segs) + overlays.append(result.overlay) except Exception: phase_progress.advance("failed") raise phase_progress.advance("completed") - return (outputs,) + return outputs, torch.cat(overlays, dim=0) def _sam_model_subject(sam_model: object) -> str: diff --git a/simple_syrup/runtime/sam_region_overlay_renderer.py b/simple_syrup/runtime/sam_region_overlay_renderer.py new file mode 100644 index 0000000..c1bebd4 --- /dev/null +++ b/simple_syrup/runtime/sam_region_overlay_renderer.py @@ -0,0 +1,142 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Render deterministic SAM-style colored overlays from retained SEGS.""" + +from __future__ import annotations + +import torch +import torch.nn.functional as functional + +from ..domain.segs import NativeSegs, Segment, coerce_segs + +_REGION_COLORS: tuple[tuple[float, float, float], ...] = ( + (0.95, 0.26, 0.21), + (0.13, 0.59, 0.95), + (0.30, 0.69, 0.31), + (1.00, 0.76, 0.03), + (0.61, 0.15, 0.69), + (1.00, 0.34, 0.13), + (0.00, 0.74, 0.83), + (0.91, 0.12, 0.39), + (0.55, 0.76, 0.29), + (0.40, 0.23, 0.72), + (1.00, 0.60, 0.00), + (0.00, 0.59, 0.53), +) + + +class SAMRegionOverlayRenderer: + """Color retained SAM regions without changing their source SEGS geometry.""" + + def render(self, *, image: torch.Tensor, segs: NativeSegs) -> torch.Tensor: + """Return a source-sized IMAGE with translucent masks and stronger edges.""" + + _validate_image(image) + (segs_height, segs_width), segments = coerce_segs(segs) + image_height = int(image.shape[1]) + image_width = int(image.shape[2]) + if (segs_height, segs_width) != (image_height, image_width): + raise ValueError( + "SAM region overlay requires SEGS dimensions to match the image." + ) + if not segments: + return image.detach().clone() + + color_channels = min(3, int(image.shape[-1])) + device = image.device + color_sum = torch.zeros( + (image_height, image_width, color_channels), + device=device, + dtype=torch.float32, + ) + coverage = torch.zeros( + (image_height, image_width, 1), + device=device, + dtype=torch.float32, + ) + boundaries = torch.zeros_like(coverage) + boundary_thickness = max(1, round(max(image_height, image_width) / 1024)) + + for index, segment in enumerate(segments): + mask = _validated_local_mask(segment, device=device) + region = segment.crop_region + color = torch.tensor( + _REGION_COLORS[index % len(_REGION_COLORS)][:color_channels], + device=device, + dtype=torch.float32, + ) + mask_channels = mask.unsqueeze(-1) + region_slice = ( + slice(region.top, region.bottom), + slice(region.left, region.right), + ) + color_sum[region_slice] += mask_channels * color + coverage[region_slice] += mask_channels + boundaries[region_slice] = torch.maximum( + boundaries[region_slice], + _mask_boundary(mask, thickness=boundary_thickness).unsqueeze(-1), + ) + + covered = coverage > 0 + mean_color = color_sum / coverage.clamp_min(1.0) + alpha = torch.where( + boundaries > 0, + torch.full_like(coverage, 0.82), + torch.full_like(coverage, 0.46), + ) + alpha = torch.where(covered, alpha, torch.zeros_like(alpha)) + output = image.detach().clone() + source_color = output[0, :, :, :color_channels].float() + output[0, :, :, :color_channels] = ( + source_color * (1.0 - alpha) + mean_color * alpha + ).to(dtype=output.dtype) + return output.clamp(0.0, 1.0) + + +def _validate_image(image: torch.Tensor) -> None: + """Reject tensors that cannot represent one ComfyUI image.""" + + if image.ndim != 4 or int(image.shape[0]) != 1: + raise ValueError("SAM region overlay requires one BHWC image.") + if int(image.shape[-1]) < 1: + raise ValueError("SAM region overlay requires at least one image channel.") + + +def _validated_local_mask( + segment: Segment, + *, + device: torch.device, +) -> torch.Tensor: + """Return one binary crop-local mask after validating its SEG geometry.""" + + mask = segment.cropped_mask + if not isinstance(mask, torch.Tensor): + raise TypeError("SAM region overlay requires tensor SEG masks.") + working = mask.detach().to(device=device, dtype=torch.float32) + if working.ndim == 3 and int(working.shape[0]) == 1: + working = working.squeeze(0) + if working.ndim != 2: + raise ValueError("SAM region overlay requires HW or 1HW SEG masks.") + expected_shape = (segment.crop_region.height, segment.crop_region.width) + if tuple(working.shape) != expected_shape: + raise ValueError("SAM region overlay mask dimensions must match its SEG crop.") + return (working >= 0.5).float() + + +def _mask_boundary(mask: torch.Tensor, *, thickness: int) -> torch.Tensor: + """Return the interior boundary band of one binary mask.""" + + kernel_size = thickness * 2 + 1 + padded = functional.pad( + mask.unsqueeze(0).unsqueeze(0), + (thickness, thickness, thickness, thickness), + value=0.0, + ) + eroded = -functional.max_pool2d( + -padded, + kernel_size=kernel_size, + stride=1, + ) + return (mask - eroded.squeeze(0).squeeze(0)).clamp(0.0, 1.0) diff --git a/simple_syrup/services/segs_from_sam_output_service.py b/simple_syrup/services/segs_from_sam_output_service.py index 403f8d4..20aab62 100644 --- a/simple_syrup/services/segs_from_sam_output_service.py +++ b/simple_syrup/services/segs_from_sam_output_service.py @@ -21,6 +21,7 @@ from ..runtime.sam_automatic_segmenter import ( SAMAutomaticSegmenter, SAMModelAutomaticSegmenter, ) +from ..runtime.sam_region_overlay_renderer import SAMRegionOverlayRenderer from ..shared.logging import get_logger LOGGER = get_logger(__name__) @@ -34,6 +35,14 @@ class SAMAutoSegsSettings: minimum_region_area: int +@dataclass(frozen=True) +class SEGSFromSAMOutputResult: + """Return retained SEGS together with their source-image visualization.""" + + segs: NativeSegs + overlay: torch.Tensor + + @dataclass(frozen=True) class _GuideMaskCandidate: """Keep a retained SAM mask in compact guide-image coordinates.""" @@ -47,10 +56,15 @@ class _GuideMaskCandidate: class SEGSFromSAMOutputService: """Build reusable SEGS from a SAM model's unprompted masks.""" - def __init__(self, segmenter: SAMAutomaticSegmenter | None = None) -> None: + def __init__( + self, + segmenter: SAMAutomaticSegmenter | None = None, + overlay_renderer: SAMRegionOverlayRenderer | None = None, + ) -> None: """Create the service with an injectable automatic segmentation runtime.""" self._segmenter = segmenter or SAMModelAutomaticSegmenter() + self._overlay_renderer = overlay_renderer or SAMRegionOverlayRenderer() def build( self, @@ -60,8 +74,8 @@ class SEGSFromSAMOutputService: segmentation_resolution: int, minimum_region_area: int, phase_progress: PhaseProgressReporter | None = None, - ) -> NativeSegs: - """Return source-sized SEGS from unprompted SAM masks.""" + ) -> SEGSFromSAMOutputResult: + """Return source-sized SEGS and their SAM-style colored overlay.""" operation_started_at = perf_counter() reporter = phase_progress or NullPhaseProgressReporter() @@ -98,6 +112,10 @@ class SEGSFromSAMOutputService: ) for index, candidate in enumerate(candidates, start=1) ) + segs: NativeSegs = (image_height, image_width), segments + segs_built_at = perf_counter() + reporter.advance("rendering_overlay") + overlay = self._overlay_renderer.render(image=source_image, segs=segs) LOGGER.info( "Built SEGS from SAM output", extra={ @@ -113,12 +131,16 @@ class SEGSFromSAMOutputService: 2, ), "segs_construction_ms": round( - (perf_counter() - masks_generated_at) * 1000.0, + (segs_built_at - masks_generated_at) * 1000.0, + 2, + ), + "overlay_rendering_ms": round( + (perf_counter() - segs_built_at) * 1000.0, 2, ), }, ) - return (image_height, image_width), segments + return SEGSFromSAMOutputResult(segs=segs, overlay=overlay) def _validate_settings( diff --git a/tests/test_sam_region_overlay_renderer.py b/tests/test_sam_region_overlay_renderer.py new file mode 100644 index 0000000..53762df --- /dev/null +++ b/tests/test_sam_region_overlay_renderer.py @@ -0,0 +1,63 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for deterministic SAM-style region overlay rendering.""" + +from __future__ import annotations + +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment +from simple_syrup.runtime.sam_region_overlay_renderer import SAMRegionOverlayRenderer + + +def test_renderer_colors_retained_regions_and_preserves_uncovered_pixels() -> None: + """The overlay colors each SEG while leaving the source visible elsewhere.""" + + image = torch.full((1, 8, 10, 3), 0.25) + first = _segment(CropRegion(1, 1, 6, 6), torch.ones((5, 5)), "first") + second_mask = torch.zeros((5, 5)) + second_mask[1:4, 1:4] = 1.0 + second = _segment(CropRegion(4, 2, 9, 7), second_mask, "second") + + overlay = SAMRegionOverlayRenderer().render( + image=image, + segs=((8, 10), (first, second)), + ) + + assert overlay.shape == image.shape + assert overlay.dtype == image.dtype + assert torch.equal(overlay[:, 0, 0], image[:, 0, 0]) + assert not torch.equal(overlay[:, 2, 2], image[:, 2, 2]) + assert not torch.equal(overlay[:, 4, 5], overlay[:, 2, 2]) + assert torch.equal(image, torch.full_like(image, 0.25)) + + +def test_renderer_is_deterministic_and_empty_segs_return_source_copy() -> None: + """Stable colors support comparisons and empty detections remain readable.""" + + image = torch.rand((1, 6, 6, 3), generator=torch.Generator().manual_seed(4)) + segment = _segment(CropRegion(1, 1, 5, 5), torch.ones((4, 4)), "subject") + renderer = SAMRegionOverlayRenderer() + + first = renderer.render(image=image, segs=((6, 6), (segment,))) + second = renderer.render(image=image, segs=((6, 6), (segment,))) + empty = renderer.render(image=image, segs=((6, 6), ())) + + assert torch.equal(first, second) + assert torch.equal(empty, image) + assert empty.data_ptr() != image.data_ptr() + + +def _segment(region: CropRegion, mask: torch.Tensor, label: str) -> Segment: + """Create one source-aligned segment for renderer tests.""" + + return Segment( + cropped_image=None, + cropped_mask=mask, + confidence=1.0, + crop_region=region, + bbox=BoundingBox(*region), + label=label, + ) diff --git a/tests/test_segs_from_sam_output_node.py b/tests/test_segs_from_sam_output_node.py index b8129c0..8a5066b 100644 --- a/tests/test_segs_from_sam_output_node.py +++ b/tests/test_segs_from_sam_output_node.py @@ -9,7 +9,11 @@ from __future__ import annotations import pytest import torch +from simple_syrup.domain.segs import NativeSegs from simple_syrup.nodes.segs_from_sam_output import SEGSFromSAMOutput +from simple_syrup.services.segs_from_sam_output_service import ( + SEGSFromSAMOutputResult, +) def test_node_declares_automatic_sam_to_segs_contract() -> None: @@ -25,8 +29,9 @@ def test_node_declares_automatic_sam_to_segs_contract() -> None: ) assert inputs["segmentation_resolution"][1]["default"] == 640 assert inputs["segmentation_resolution"][1]["step"] == 64 - assert SEGSFromSAMOutput.RETURN_TYPES == ("SEGS",) - assert SEGSFromSAMOutput.OUTPUT_IS_LIST == (True,) + assert SEGSFromSAMOutput.RETURN_TYPES == ("SEGS", "IMAGE") + assert SEGSFromSAMOutput.RETURN_NAMES == ("segs", "overlay") + assert SEGSFromSAMOutput.OUTPUT_IS_LIST == (True, False) def test_node_builds_one_segs_output_per_image_batch_item( @@ -52,9 +57,17 @@ def test_node_builds_one_segs_output_per_image_batch_item( "preparing_segmentation_image", "generating_automatic_masks", "building_segs", + "rendering_overlay", ): phase_progress.advance(phase) - return (image.shape[1:3], ()) + empty_segs: NativeSegs = ( + (int(image.shape[1]), int(image.shape[2])), + (), + ) + return SEGSFromSAMOutputResult( + segs=empty_segs, + overlay=image + len(calls) / 10.0, + ) monkeypatch.setattr(SEGSFromSAMOutput, "service_class", _Service) monkeypatch.setattr( @@ -63,7 +76,7 @@ def test_node_builds_one_segs_output_per_image_batch_item( lambda **_kwargs: phase_progress, ) - (segs,) = SEGSFromSAMOutput().generate( + segs, overlay = SEGSFromSAMOutput().generate( image=torch.zeros((2, 16, 16, 3)), sam_model=object(), segmentation_resolution=640, @@ -72,13 +85,18 @@ def test_node_builds_one_segs_output_per_image_batch_item( assert len(calls) == 2 assert len(segs) == 2 + assert overlay.shape == (2, 16, 16, 3) + assert torch.allclose(overlay[0], torch.full((16, 16, 3), 0.1)) + assert torch.allclose(overlay[1], torch.full((16, 16, 3), 0.2)) assert phase_progress.phases == [ "preparing_segmentation_image", "generating_automatic_masks", "building_segs", + "rendering_overlay", "preparing_segmentation_image", "generating_automatic_masks", "building_segs", + "rendering_overlay", "completed", ] diff --git a/tests/test_segs_from_sam_output_service.py b/tests/test_segs_from_sam_output_service.py index 5be626f..c88837f 100644 --- a/tests/test_segs_from_sam_output_service.py +++ b/tests/test_segs_from_sam_output_service.py @@ -59,13 +59,14 @@ def test_service_downscales_the_segmentation_guide_without_upscaling_source() -> runtime = _RecordingSegmenter((AutomaticSAMMask(guide_mask, 0.8),)) service = SEGSFromSAMOutputService(runtime) - segs = service.build( + result = service.build( image=torch.zeros((1, 128, 256, 3)), sam_model=object(), segmentation_resolution=64, minimum_region_area=0, ) + segs = result.segs assert runtime.image_shapes == [(32, 64)] assert segs[0] == (128, 256) segment = segs[1][0] @@ -94,6 +95,7 @@ def test_service_reports_meaningful_automatic_segmentation_phases() -> None: "preparing_segmentation_image", "generating_automatic_masks", "building_segs", + "rendering_overlay", ] @@ -105,21 +107,21 @@ def test_service_filters_region_area_after_restoring_source_dimensions() -> None runtime = _RecordingSegmenter((AutomaticSAMMask(guide_mask, 0.5, "thing"),)) service = SEGSFromSAMOutputService(runtime) - retained = service.build( + retained_result = service.build( image=torch.zeros((1, 128, 256, 3)), sam_model=object(), segmentation_resolution=64, minimum_region_area=63, ) - filtered = service.build( + filtered_result = service.build( image=torch.zeros((1, 128, 256, 3)), sam_model=object(), segmentation_resolution=64, minimum_region_area=65, ) - assert retained[1][0].label == "thing" - assert filtered[1] == () + assert retained_result.segs[1][0].label == "thing" + assert filtered_result.segs[1] == () def test_service_suppresses_duplicate_masks_and_keeps_highest_confidence() -> None: @@ -133,16 +135,16 @@ def test_service_suppresses_duplicate_masks_and_keeps_highest_confidence() -> No ) ) - segs = SEGSFromSAMOutputService(runtime).build( + result = SEGSFromSAMOutputService(runtime).build( image=torch.zeros((1, 16, 16, 3)), sam_model=object(), segmentation_resolution=64, minimum_region_area=0, ) - assert len(segs[1]) == 1 - assert segs[1][0].confidence == 0.9 - assert segs[1][0].label == "second" + assert len(result.segs[1]) == 1 + assert result.segs[1][0].confidence == 0.9 + assert result.segs[1][0].label == "second" def test_service_expands_retained_mask_crops_to_source_resolution() -> None: @@ -151,7 +153,7 @@ def test_service_expands_retained_mask_crops_to_source_resolution() -> None: guide_mask = torch.zeros((64, 128), dtype=torch.float32) guide_mask[16:32, 32:64] = 1.0 - segs = SEGSFromSAMOutputService( + result = SEGSFromSAMOutputService( _RecordingSegmenter((AutomaticSAMMask(guide_mask, 1.0),)) ).build( image=torch.zeros((1, 1024, 2048, 3)), @@ -160,7 +162,7 @@ def test_service_expands_retained_mask_crops_to_source_resolution() -> None: minimum_region_area=0, ) - segment = segs[1][0] + segment = result.segs[1][0] assert segment.crop_region == (512, 256, 1024, 512) assert cast(torch.Tensor, segment.cropped_mask).shape == (256, 512) From 6a6c3f8362657b429396ea59b458f371d1b37899 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sun, 2 Aug 2026 02:06:49 -0400 Subject: [PATCH 28/40] feat(segmentation): add interactive SEGS preview --- simple_syrup/domain/seg_visualization.py | 118 +++ simple_syrup/domain/segs.py | 21 + simple_syrup/domain/segs_tiled_diffusion.py | 13 +- simple_syrup/nodes/simple_preview_segs.py | 64 ++ simple_syrup/nodes_v3/__init__.py | 2 + simple_syrup/nodes_v3/legacy_node_wrappers.py | 10 + .../runtime/sam_region_overlay_renderer.py | 53 +- simple_syrup/runtime/seg_preview_assets.py | 92 +++ .../services/segs_detection_service.py | 8 +- .../services/segs_from_sam_output_service.py | 2 +- simple_syrup/services/segs_output_service.py | 21 +- .../services/simple_preview_segs_service.py | 233 ++++++ tests/test_registration.py | 1 + tests/test_seg_preview_assets.py | 143 ++++ tests/test_seg_visualization.py | 54 ++ tests/test_segs_from_sam_output_service.py | 4 +- tests/test_segs_output_service.py | 25 +- tests/test_simple_preview_segs_node.py | 79 ++ tests/test_simple_preview_segs_service.py | 75 ++ web/dist/simple-syrup.js | 781 +++++++++++++++++- web/src/imageViewport.ts | 44 + web/src/interactiveInspector.ts | 76 ++ web/src/main.ts | 10 +- web/src/maskAtlas.ts | 100 +++ web/src/segPreviewInspector.ts | 439 ++++++++++ web/src/segPreviewNode.ts | 139 ++++ web/src/segPreviewTypes.ts | 181 ++++ web/src/selectionModel.ts | 72 ++ web/src/types.ts | 7 + web/tests/imageViewport.test.ts | 22 + web/tests/interactiveInspector.test.ts | 117 +++ web/tests/maskAtlas.test.ts | 74 ++ web/tests/segPreviewInspector.test.ts | 99 +++ web/tests/segPreviewNode.test.ts | 141 ++++ web/tests/segPreviewTypes.test.ts | 59 ++ web/tests/selectionModel.test.ts | 36 + web/tests/testUtils.ts | 41 + 37 files changed, 3368 insertions(+), 88 deletions(-) create mode 100644 simple_syrup/domain/seg_visualization.py create mode 100644 simple_syrup/nodes/simple_preview_segs.py create mode 100644 simple_syrup/runtime/seg_preview_assets.py create mode 100644 simple_syrup/services/simple_preview_segs_service.py create mode 100644 tests/test_seg_preview_assets.py create mode 100644 tests/test_seg_visualization.py create mode 100644 tests/test_simple_preview_segs_node.py create mode 100644 tests/test_simple_preview_segs_service.py create mode 100644 web/src/imageViewport.ts create mode 100644 web/src/interactiveInspector.ts create mode 100644 web/src/maskAtlas.ts create mode 100644 web/src/segPreviewInspector.ts create mode 100644 web/src/segPreviewNode.ts create mode 100644 web/src/segPreviewTypes.ts create mode 100644 web/src/selectionModel.ts create mode 100644 web/tests/imageViewport.test.ts create mode 100644 web/tests/interactiveInspector.test.ts create mode 100644 web/tests/maskAtlas.test.ts create mode 100644 web/tests/segPreviewInspector.test.ts create mode 100644 web/tests/segPreviewNode.test.ts create mode 100644 web/tests/segPreviewTypes.test.ts create mode 100644 web/tests/selectionModel.test.ts diff --git a/simple_syrup/domain/seg_visualization.py b/simple_syrup/domain/seg_visualization.py new file mode 100644 index 0000000..1f6ac20 --- /dev/null +++ b/simple_syrup/domain/seg_visualization.py @@ -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.") diff --git a/simple_syrup/domain/segs.py b/simple_syrup/domain/segs.py index b68158f..f295d74 100644 --- a/simple_syrup/domain/segs.py +++ b/simple_syrup/domain/segs.py @@ -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.""" diff --git a/simple_syrup/domain/segs_tiled_diffusion.py b/simple_syrup/domain/segs_tiled_diffusion.py index d3f528e..3a3d694 100644 --- a/simple_syrup/domain/segs_tiled_diffusion.py +++ b/simple_syrup/domain/segs_tiled_diffusion.py @@ -11,7 +11,7 @@ from dataclasses import dataclass import torch import torch.nn.functional as functional -from .segs import NativeSegs, Segment, coerce_segs +from .segs import NativeSegs, Segment, coerce_segment_mask, coerce_segs from .tiled_diffusion import ( LatentTile, TiledDiffusionPlan, @@ -215,16 +215,7 @@ def segment_weight_to_latent( "SEGS-guided tiled diffusion requires every SEG crop_region to fit " "inside the SEGS header dimensions." ) - local_mask = ( - torch.as_tensor(segment.cropped_mask, dtype=torch.float32).detach().cpu() - ) - if local_mask.ndim == 3 and int(local_mask.shape[0]) == 1: - local_mask = local_mask.squeeze(0) - if local_mask.shape != (crop.height, crop.width): - raise ValueError( - "SEGS-guided tiled diffusion requires each cropped_mask to match its " - "crop_region." - ) + local_mask = coerce_segment_mask(segment).detach().cpu() latent_top, latent_bottom = _latent_sample_range( crop.top, crop.bottom, diff --git a/simple_syrup/nodes/simple_preview_segs.py b/simple_syrup/nodes/simple_preview_segs.py new file mode 100644 index 0000000..87e52ef --- /dev/null +++ b/simple_syrup/nodes/simple_preview_segs.py @@ -0,0 +1,64 @@ +# 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 the interactive Simple SEGS inspector.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from ..domain.segs import coerce_segs +from ..runtime.seg_preview_assets import ( + SEG_PREVIEW_UI_KEY, + ComfySegPreviewAssetPublisher, +) +from ..services.simple_preview_segs_service import SimplePreviewSEGSService + + +class SimplePreviewSEGS: + """Expose an interactive overlay and grid inspector for one IMAGE and SEGS.""" + + service_class: ClassVar[type[SimplePreviewSEGSService]] = SimplePreviewSEGSService + publisher_class: ClassVar[type[ComfySegPreviewAssetPublisher]] = ( + ComfySegPreviewAssetPublisher + ) + + RETURN_TYPES = ("SEGS",) + RETURN_NAMES = ("segs",) + OUTPUT_TOOLTIPS = ("The original SEGS passed through without modification.",) + FUNCTION = "preview" + CATEGORY = "SimpleSyrup/Preview" + OUTPUT_NODE = True + DESCRIPTION = ( + "Interactively inspects SEGS over the original image or in a selectable grid." + ) + SEARCH_ALIASES = ["segs", "regions", "inspect", "overlay", "preview"] + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]: + """Declare the source image and matching SEGS inputs.""" + + return { + "required": { + "image": ( + "IMAGE", + {"tooltip": "Original image described by the connected SEGS."}, + ), + "segs": ( + "SEGS", + {"tooltip": "Regions to inspect over the original image."}, + ), + } + } + + def preview(self, image: object, segs: object) -> dict[str, object]: + """Publish an interactive preview while returning the input SEGS unchanged.""" + + native_segs = coerce_segs(segs) + document = self.service_class().build(image=image, segs=native_segs) + manifest = self.publisher_class().publish(document) + return { + "ui": {SEG_PREVIEW_UI_KEY: [manifest]}, + "result": (segs,), + } diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index b282d50..79ebb24 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -42,6 +42,7 @@ def get_nodes() -> list[type[object]]: SeedV3, SEGSFromSAMOutputV3, SimpleLoadAnimaV3, + SimplePreviewSEGSV3, SimpleVAEEncodeV3, UpscaleLatentFromImageV3, ViTMatteModelLoaderV3, @@ -90,6 +91,7 @@ def get_nodes() -> list[type[object]]: ScaleFactorV3, SeedV3, SimpleLoadAnimaV3, + SimplePreviewSEGSV3, SimpleLoadCheckpointV3, SimpleVAEEncodeV3, TagSEGSWithExternalLLMV3, diff --git a/simple_syrup/nodes_v3/legacy_node_wrappers.py b/simple_syrup/nodes_v3/legacy_node_wrappers.py index 4e075ad..b60fd20 100644 --- a/simple_syrup/nodes_v3/legacy_node_wrappers.py +++ b/simple_syrup/nodes_v3/legacy_node_wrappers.py @@ -40,6 +40,7 @@ from ..nodes.sam_model_loader import SAMModelLoader from ..nodes.seed import Seed from ..nodes.segs_from_sam_output import SEGSFromSAMOutput from ..nodes.simple_load_anima import SimpleLoadAnima +from ..nodes.simple_preview_segs import SimplePreviewSEGS from ..nodes.vitmatte_model_loader import ViTMatteModelLoader if TYPE_CHECKING: @@ -272,6 +273,14 @@ class SEGSFromSAMOutputV3(LegacyNodeV3Adapter): DISPLAY_NAME = "SEGS from SAM Output" +class SimplePreviewSEGSV3(LegacyNodeV3Adapter): + """Expose the interactive Simple Preview SEGS node through Comfy v3 only.""" + + LEGACY_NODE_CLASS = SimplePreviewSEGS + NODE_ID = "SimpleSyrup.SimplePreviewSEGS" + DISPLAY_NAME = "Simple Preview SEGS" + + class SeedV3(LegacyNodeV3Adapter): """Expose Seed through Comfy v3 only.""" @@ -527,6 +536,7 @@ __all__ = [ "SEGSFromSAMOutputV3", "SeedV3", "SimpleLoadAnimaV3", + "SimplePreviewSEGSV3", "SimpleVAEEncodeV3", "UpscaleLatentFromImageV3", "ViTMatteModelLoaderV3", diff --git a/simple_syrup/runtime/sam_region_overlay_renderer.py b/simple_syrup/runtime/sam_region_overlay_renderer.py index c1bebd4..e92d653 100644 --- a/simple_syrup/runtime/sam_region_overlay_renderer.py +++ b/simple_syrup/runtime/sam_region_overlay_renderer.py @@ -9,22 +9,8 @@ from __future__ import annotations import torch import torch.nn.functional as functional -from ..domain.segs import NativeSegs, Segment, coerce_segs - -_REGION_COLORS: tuple[tuple[float, float, float], ...] = ( - (0.95, 0.26, 0.21), - (0.13, 0.59, 0.95), - (0.30, 0.69, 0.31), - (1.00, 0.76, 0.03), - (0.61, 0.15, 0.69), - (1.00, 0.34, 0.13), - (0.00, 0.74, 0.83), - (0.91, 0.12, 0.39), - (0.55, 0.76, 0.29), - (0.40, 0.23, 0.72), - (1.00, 0.60, 0.00), - (0.00, 0.59, 0.53), -) +from ..domain.seg_visualization import build_seg_visualization_plan +from ..domain.segs import NativeSegs class SAMRegionOverlayRenderer: @@ -34,14 +20,14 @@ class SAMRegionOverlayRenderer: """Return a source-sized IMAGE with translucent masks and stronger edges.""" _validate_image(image) - (segs_height, segs_width), segments = coerce_segs(segs) + plan = build_seg_visualization_plan(segs) image_height = int(image.shape[1]) image_width = int(image.shape[2]) - if (segs_height, segs_width) != (image_height, image_width): + if (plan.source_height, plan.source_width) != (image_height, image_width): raise ValueError( "SAM region overlay requires SEGS dimensions to match the image." ) - if not segments: + if not plan.regions: return image.detach().clone() color_channels = min(3, int(image.shape[-1])) @@ -59,11 +45,11 @@ class SAMRegionOverlayRenderer: boundaries = torch.zeros_like(coverage) boundary_thickness = max(1, round(max(image_height, image_width) / 1024)) - for index, segment in enumerate(segments): - mask = _validated_local_mask(segment, device=device) - region = segment.crop_region + for region_plan in plan.regions: + mask = (region_plan.mask >= 0.5).to(device=device, dtype=torch.float32) + region = region_plan.crop_region color = torch.tensor( - _REGION_COLORS[index % len(_REGION_COLORS)][:color_channels], + region_plan.color.normalized[:color_channels], device=device, dtype=torch.float32, ) @@ -104,27 +90,6 @@ def _validate_image(image: torch.Tensor) -> None: raise ValueError("SAM region overlay requires at least one image channel.") -def _validated_local_mask( - segment: Segment, - *, - device: torch.device, -) -> torch.Tensor: - """Return one binary crop-local mask after validating its SEG geometry.""" - - mask = segment.cropped_mask - if not isinstance(mask, torch.Tensor): - raise TypeError("SAM region overlay requires tensor SEG masks.") - working = mask.detach().to(device=device, dtype=torch.float32) - if working.ndim == 3 and int(working.shape[0]) == 1: - working = working.squeeze(0) - if working.ndim != 2: - raise ValueError("SAM region overlay requires HW or 1HW SEG masks.") - expected_shape = (segment.crop_region.height, segment.crop_region.width) - if tuple(working.shape) != expected_shape: - raise ValueError("SAM region overlay mask dimensions must match its SEG crop.") - return (working >= 0.5).float() - - def _mask_boundary(mask: torch.Tensor, *, thickness: int) -> torch.Tensor: """Return the interior boundary band of one binary mask.""" diff --git a/simple_syrup/runtime/seg_preview_assets.py b/simple_syrup/runtime/seg_preview_assets.py new file mode 100644 index 0000000..a78255f --- /dev/null +++ b/simple_syrup/runtime/seg_preview_assets.py @@ -0,0 +1,92 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Publish interactive SEG preview assets through ComfyUI temporary storage.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any, Protocol + +import torch + +from ..services.simple_preview_segs_service import SegPreviewDocument + +SEG_PREVIEW_UI_KEY = "simple_syrup_segs_preview" + + +class SegPreviewAssetPublisher(Protocol): + """Publish one backend preview document as a JSON-compatible UI payload.""" + + def publish(self, document: SegPreviewDocument) -> dict[str, object]: + """Return a frontend-ready manifest with temporary asset references.""" + + +class ComfySegPreviewAssetPublisher: + """Store preview tensors using ComfyUI's authoritative image helper.""" + + def publish(self, document: SegPreviewDocument) -> dict[str, object]: + """Return one versioned manifest referencing a base image and mask atlas.""" + + comfy_api: Any = import_module("comfy_api.latest") + image_ref = _publish_one(comfy_api, document.image) + atlas_ref = _publish_one(comfy_api, document.atlas) + return { + "version": 1, + "source": { + "width": document.source_width, + "height": document.source_height, + }, + "preview": { + "width": document.preview_width, + "height": document.preview_height, + "image": image_ref, + }, + "atlas": { + "width": int(document.atlas.shape[2]), + "height": int(document.atlas.shape[1]), + "image": atlas_ref, + }, + "regions": [ + { + "id": region.region_id, + "index": region.index, + "label": region.label, + "confidence": region.confidence, + "area": region.active_area, + "color": region.color, + "crop": { + "x": region.crop.left, + "y": region.crop.top, + "width": region.crop.width, + "height": region.crop.height, + }, + "atlas": { + "x": region.atlas.left, + "y": region.atlas.top, + "width": region.atlas.width, + "height": region.atlas.height, + }, + } + for region in document.regions + ], + } + + +def _publish_one(comfy_api: Any, image: torch.Tensor) -> dict[str, str]: + """Publish one IMAGE tensor and validate ComfyUI's returned reference.""" + + payload: object = comfy_api.UI.PreviewImage(image).as_dict() + if not isinstance(payload, dict): + raise TypeError("ComfyUI returned an invalid SEG preview asset payload.") + images = payload.get("images") + if not isinstance(images, (list, tuple)) or len(images) != 1: + raise TypeError("ComfyUI must publish exactly one SEG preview asset.") + reference = images[0] + if not isinstance(reference, dict): + raise TypeError("ComfyUI returned an invalid SEG preview image reference.") + required = ("filename", "subfolder", "type") + if any(not isinstance(reference.get(name), str) for name in required): + raise TypeError("ComfyUI SEG preview image reference is incomplete.") + return {name: str(reference[name]) for name in required} diff --git a/simple_syrup/services/segs_detection_service.py b/simple_syrup/services/segs_detection_service.py index 345b599..bdb33af 100644 --- a/simple_syrup/services/segs_detection_service.py +++ b/simple_syrup/services/segs_detection_service.py @@ -10,7 +10,7 @@ from typing import Protocol import torch -from ..domain.segs import NativeSegs, Segment +from ..domain.segs import NativeSegs, Segment, coerce_segment_mask from ..masking.segs_mask_ops import ( crop_image, crop_mask, @@ -25,7 +25,7 @@ from ..runtime.ultralytics_detection import ( ) from ..runtime.ultralytics_loader import UltralyticsDetectorModel from ..shared.logging import get_logger -from .segs_output_service import coerce_cropped_mask, combined_mask_from_segs +from .segs_output_service import combined_mask_from_segs LOGGER = get_logger(__name__) @@ -208,7 +208,7 @@ def _intersect_segs_with_combined_mask( combined_mask = combined_mask_from_segs(mask_segs) refined_segments: list[Segment] = [] for segment in segments: - cropped_mask = coerce_cropped_mask(segment) + cropped_mask = coerce_segment_mask(segment) refinement_mask = crop_mask(combined_mask, segment.crop_region) refined_segments.append( Segment( @@ -234,7 +234,7 @@ def _dilate_cropped_segs(segs: NativeSegs, dilation: int) -> NativeSegs: return header, tuple( Segment( cropped_image=segment.cropped_image, - cropped_mask=dilate_mask(coerce_cropped_mask(segment), dilation), + cropped_mask=dilate_mask(coerce_segment_mask(segment), dilation), confidence=segment.confidence, crop_region=segment.crop_region, bbox=segment.bbox, diff --git a/simple_syrup/services/segs_from_sam_output_service.py b/simple_syrup/services/segs_from_sam_output_service.py index 20aab62..9a7ed92 100644 --- a/simple_syrup/services/segs_from_sam_output_service.py +++ b/simple_syrup/services/segs_from_sam_output_service.py @@ -309,7 +309,7 @@ def _build_segment( ] .detach() .clone(), - cropped_mask=local_mask.detach().clone(), + cropped_mask=local_mask.unsqueeze(0).detach().clone(), confidence=max(0.0, min(1.0, float(confidence))), crop_region=crop_region, bbox=BoundingBox( diff --git a/simple_syrup/services/segs_output_service.py b/simple_syrup/services/segs_output_service.py index 94d0466..bd2d432 100644 --- a/simple_syrup/services/segs_output_service.py +++ b/simple_syrup/services/segs_output_service.py @@ -16,6 +16,7 @@ from ..domain.segs import ( ImpactSegs, NativeSegs, Segment, + coerce_segment_mask, limit_segs, sort_segs, to_impact_compatible_segs, @@ -118,7 +119,7 @@ def combined_mask_from_segs(segs: NativeSegs) -> torch.Tensor: height, width = header mask = torch.zeros((height, width), dtype=torch.float32) for segment in segments: - cropped_mask = coerce_cropped_mask(segment) + cropped_mask = coerce_segment_mask(segment) region = segment.crop_region existing = mask[region.top : region.bottom, region.left : region.right] mask[region.top : region.bottom, region.left : region.right] = torch.maximum( @@ -126,21 +127,3 @@ def combined_mask_from_segs(segs: NativeSegs) -> torch.Tensor: cropped_mask.float().cpu(), ) return mask.clamp(0.0, 1.0) - - -def coerce_cropped_mask(segment: Segment) -> torch.Tensor: - """Return a crop-local HW mask tensor for a segment.""" - - if isinstance(segment.cropped_mask, torch.Tensor): - cropped_mask = segment.cropped_mask.float() - else: - cropped_mask = torch.as_tensor(segment.cropped_mask, dtype=torch.float32) - if cropped_mask.ndim == 3 and int(cropped_mask.shape[0]) == 1: - cropped_mask = cropped_mask.squeeze(0) - if cropped_mask.ndim != 2: - raise ValueError("Segment cropped_mask must be HW shaped.") - expected_shape = (segment.crop_region.height, segment.crop_region.width) - actual_shape = (int(cropped_mask.shape[0]), int(cropped_mask.shape[1])) - if actual_shape != expected_shape: - raise ValueError("Segment cropped_mask must match its crop region.") - return cropped_mask.clamp(0.0, 1.0) diff --git a/simple_syrup/services/simple_preview_segs_service.py b/simple_syrup/services/simple_preview_segs_service.py new file mode 100644 index 0000000..e574d33 --- /dev/null +++ b/simple_syrup/services/simple_preview_segs_service.py @@ -0,0 +1,233 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Build compact renderer-neutral preview documents from IMAGE and SEGS.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import ceil, sqrt + +import torch +import torch.nn.functional as functional + +from ..domain.seg_visualization import ( + SegVisualizationPlan, + build_seg_visualization_plan, +) +from ..domain.segs import CropRegion, NativeSegs +from ..masking.segs_mask_ops import validate_single_image + +_MAX_PREVIEW_EDGE = 1024 +_ATLAS_PIXEL_BUDGET = 4 * 1024 * 1024 +_MAX_ATLAS_EDGE = 2048 +_ATLAS_PADDING = 1 + + +@dataclass(frozen=True) +class AtlasPlacement: + """Locate one region mask inside the packed mask atlas.""" + + left: int + top: int + width: int + height: int + + +@dataclass(frozen=True) +class SegPreviewRegion: + """Describe one interactive region and its packed mask geometry.""" + + region_id: str + index: int + label: str + confidence: float + active_area: int + color: str + crop: CropRegion + atlas: AtlasPlacement + + +@dataclass(frozen=True) +class SegPreviewDocument: + """Carry bounded image assets and interaction metadata to the UI adapter.""" + + source_width: int + source_height: int + preview_width: int + preview_height: int + image: torch.Tensor + atlas: torch.Tensor + regions: tuple[SegPreviewRegion, ...] + + +class SimplePreviewSEGSService: + """Create one compact interactive-preview document without ComfyUI IO.""" + + def build(self, *, image: object, segs: NativeSegs) -> SegPreviewDocument: + """Return a bounded base image, packed masks, and deterministic metadata.""" + + source_image = validate_single_image(image, "Simple Preview SEGS") + plan = build_seg_visualization_plan(segs) + image_height = int(source_image.shape[1]) + image_width = int(source_image.shape[2]) + if (plan.source_height, plan.source_width) != (image_height, image_width): + raise ValueError( + "Simple Preview SEGS requires IMAGE and SEGS dimensions to match." + ) + + preview_width, preview_height = _fit_dimensions( + image_width, + image_height, + _MAX_PREVIEW_EDGE, + ) + preview_image = _resize_image( + source_image, + width=preview_width, + height=preview_height, + ) + atlas, regions = _build_atlas(plan) + return SegPreviewDocument( + source_width=image_width, + source_height=image_height, + preview_width=preview_width, + preview_height=preview_height, + image=preview_image, + atlas=atlas, + regions=regions, + ) + + +def _build_atlas( + plan: SegVisualizationPlan, +) -> tuple[torch.Tensor, tuple[SegPreviewRegion, ...]]: + """Pack every crop-local mask into one bounded RGB atlas.""" + + if not plan.regions: + return torch.zeros((1, 1, 1, 3), dtype=torch.float32), () + + total_crop_area = sum( + region.crop_region.width * region.crop_region.height for region in plan.regions + ) + preview_scale = min( + 1.0, + _MAX_PREVIEW_EDGE / max(plan.source_width, plan.source_height), + ) + budget_scale = sqrt(_ATLAS_PIXEL_BUDGET / max(1, total_crop_area)) + scale = min(preview_scale, budget_scale) + + while True: + sizes = tuple( + ( + max(1, round(region.crop_region.width * scale)), + max(1, round(region.crop_region.height * scale)), + ) + for region in plan.regions + ) + placements, atlas_width, atlas_height = _pack_rectangles(sizes) + if atlas_width <= _MAX_ATLAS_EDGE and atlas_height <= _MAX_ATLAS_EDGE: + break + scale *= 0.85 + if scale < 1.0 / max(plan.source_width, plan.source_height): + raise ValueError("Simple Preview SEGS could not pack the region masks.") + + atlas = torch.zeros((atlas_height, atlas_width), dtype=torch.float32) + metadata: list[SegPreviewRegion] = [] + for visual_region, placement in zip(plan.regions, placements, strict=True): + resized = ( + functional.interpolate( + visual_region.mask.unsqueeze(0).unsqueeze(0).float(), + size=(placement.height, placement.width), + mode="nearest-exact", + ) + .squeeze(0) + .squeeze(0) + ) + atlas[ + placement.top : placement.top + placement.height, + placement.left : placement.left + placement.width, + ] = resized + metadata.append( + SegPreviewRegion( + region_id=visual_region.region_id, + index=visual_region.index, + label=visual_region.segment.label, + confidence=float(visual_region.segment.confidence), + active_area=visual_region.active_area, + color=visual_region.color.css, + crop=visual_region.crop_region, + atlas=placement, + ) + ) + return atlas.unsqueeze(0).unsqueeze(-1).expand(-1, -1, -1, 3), tuple(metadata) + + +def _pack_rectangles( + sizes: tuple[tuple[int, int], ...], +) -> tuple[tuple[AtlasPlacement, ...], int, int]: + """Pack ordered rectangles into deterministic height-sorted shelves.""" + + padded_area = sum( + (width + _ATLAS_PADDING) * (height + _ATLAS_PADDING) for width, height in sizes + ) + widest = max(width for width, _height in sizes) + shelf_width = min( + _MAX_ATLAS_EDGE, + max(widest, ceil(sqrt(max(1, padded_area)))), + ) + indexed = sorted( + enumerate(sizes), + key=lambda item: (-item[1][1], -item[1][0], item[0]), + ) + placements: list[AtlasPlacement | None] = [None] * len(sizes) + x = 0 + y = 0 + shelf_height = 0 + used_width = 1 + for index, (width, height) in indexed: + if x > 0 and x + width > shelf_width: + y += shelf_height + _ATLAS_PADDING + x = 0 + shelf_height = 0 + placements[index] = AtlasPlacement(x, y, width, height) + used_width = max(used_width, x + width) + x += width + _ATLAS_PADDING + shelf_height = max(shelf_height, height) + used_height = max(1, y + shelf_height) + return ( + tuple(placement for placement in placements if placement is not None), + used_width, + used_height, + ) + + +def _fit_dimensions(width: int, height: int, maximum: int) -> tuple[int, int]: + """Fit image dimensions inside one maximum edge without upscaling.""" + + if max(width, height) <= maximum: + return width, height + scale = maximum / max(width, height) + return max(1, round(width * scale)), max(1, round(height * scale)) + + +def _resize_image( + image: torch.Tensor, + *, + width: int, + height: int, +) -> torch.Tensor: + """Resize one BHWC image while preserving its channel count.""" + + if image.shape[1:3] == (height, width): + return image.detach().cpu().clone() + return ( + functional.interpolate( + image.detach().cpu().movedim(-1, 1).float(), + size=(height, width), + mode="bilinear", + align_corners=False, + ) + .movedim(1, -1) + .clamp(0.0, 1.0) + ) diff --git a/tests/test_registration.py b/tests/test_registration.py index c3b842b..3f3ed5c 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -49,6 +49,7 @@ BASE_NODE_IDS = [ "SimpleSyrup.ScaleFactor", "SimpleSyrup.Seed", "SimpleSyrup.SimpleLoadAnima", + "SimpleSyrup.SimplePreviewSEGS", "SimpleSyrup.SimpleLoadCheckpoint", "SimpleSyrup.SimpleVAEEncode", "SimpleSyrup.TagSEGSWithExternalLLM", diff --git a/tests/test_seg_preview_assets.py b/tests/test_seg_preview_assets.py new file mode 100644 index 0000000..bcffdb0 --- /dev/null +++ b/tests/test_seg_preview_assets.py @@ -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 + +"""Tests for ComfyUI temporary-asset publication for SEG previews.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import ClassVar + +import pytest +import torch +from pytest import MonkeyPatch + +from simple_syrup.domain.segs import CropRegion +from simple_syrup.runtime.seg_preview_assets import ComfySegPreviewAssetPublisher +from simple_syrup.services.simple_preview_segs_service import ( + AtlasPlacement, + SegPreviewDocument, + SegPreviewRegion, +) + + +def test_publisher_stores_both_assets_and_builds_versioned_manifest( + monkeypatch: MonkeyPatch, +) -> None: + """The runtime adapter translates Comfy references without leaking tensors.""" + + class _PreviewImage: + """Return one distinct Comfy image reference per published tensor.""" + + calls: ClassVar[list[torch.Tensor]] = [] + + def __init__(self, image: torch.Tensor) -> None: + self.calls.append(image) + + def as_dict(self) -> dict[str, object]: + """Return the reference for the most recent call.""" + + index = len(self.calls) + return { + "images": [ + { + "filename": f"asset-{index}.png", + "subfolder": "preview", + "type": "temp", + } + ] + } + + fake_api = SimpleNamespace(UI=SimpleNamespace(PreviewImage=_PreviewImage)) + monkeypatch.setattr( + "simple_syrup.runtime.seg_preview_assets.import_module", + lambda _name: fake_api, + ) + + document = _document() + manifest = ComfySegPreviewAssetPublisher().publish(document) + + assert _PreviewImage.calls == [document.image, document.atlas] + assert manifest["version"] == 1 + assert manifest["preview"] == { + "width": 4, + "height": 3, + "image": { + "filename": "asset-1.png", + "subfolder": "preview", + "type": "temp", + }, + } + assert manifest["atlas"] == { + "width": 2, + "height": 1, + "image": { + "filename": "asset-2.png", + "subfolder": "preview", + "type": "temp", + }, + } + assert manifest["regions"] == [ + { + "id": "seg-0001", + "index": 0, + "label": "subject", + "confidence": 0.9, + "area": 6, + "color": "#f24236", + "crop": {"x": 1, "y": 0, "width": 2, "height": 3}, + "atlas": {"x": 0, "y": 0, "width": 2, "height": 1}, + } + ] + + +def test_publisher_rejects_malformed_comfy_image_reference( + monkeypatch: MonkeyPatch, +) -> None: + """Invalid host output fails explicitly before the frontend sees it.""" + + class _InvalidPreviewImage: + """Return a payload missing Comfy's required image reference fields.""" + + def __init__(self, _image: torch.Tensor) -> None: + pass + + def as_dict(self) -> dict[str, object]: + """Return one intentionally incomplete reference.""" + + return {"images": [{"filename": "asset.png"}]} + + fake_api = SimpleNamespace(UI=SimpleNamespace(PreviewImage=_InvalidPreviewImage)) + monkeypatch.setattr( + "simple_syrup.runtime.seg_preview_assets.import_module", + lambda _name: fake_api, + ) + + with pytest.raises(TypeError, match="reference is incomplete"): + ComfySegPreviewAssetPublisher().publish(_document()) + + +def _document() -> SegPreviewDocument: + """Return one small document with stable transport metadata.""" + + return SegPreviewDocument( + source_width=4, + source_height=3, + preview_width=4, + preview_height=3, + image=torch.zeros((1, 3, 4, 3)), + atlas=torch.ones((1, 1, 2, 3)), + regions=( + SegPreviewRegion( + region_id="seg-0001", + index=0, + label="subject", + confidence=0.9, + active_area=6, + color="#f24236", + crop=CropRegion(1, 0, 3, 3), + atlas=AtlasPlacement(0, 0, 2, 1), + ), + ), + ) diff --git a/tests/test_seg_visualization.py b/tests/test_seg_visualization.py new file mode 100644 index 0000000..6e78967 --- /dev/null +++ b/tests/test_seg_visualization.py @@ -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 + +"""Tests for shared SEG visualization planning.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.domain.seg_visualization import build_seg_visualization_plan +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment + + +def test_plan_assigns_stable_ids_colors_and_normalized_masks() -> None: + """The shared plan owns deterministic visualization identity and geometry.""" + + first = _segment(CropRegion(1, 2, 4, 4), torch.ones((1, 2, 3)), "face") + second_mask = torch.tensor([[1.0, 0.0], [1.0, 0.0]]) + second = _segment(CropRegion(5, 1, 7, 3), second_mask, "hand") + + plan = build_seg_visualization_plan(((8, 10), (first, second))) + + assert (plan.source_height, plan.source_width) == (8, 10) + assert [region.region_id for region in plan.regions] == [ + "seg-0001", + "seg-0002", + ] + assert [region.color.css for region in plan.regions] == ["#f24236", "#2196f3"] + assert plan.regions[0].mask.shape == (2, 3) + assert plan.regions[1].active_area == 2 + + +def test_plan_rejects_crop_outside_declared_source() -> None: + """Invalid source geometry fails before any renderer consumes it.""" + + segment = _segment(CropRegion(7, 7, 10, 10), torch.ones((3, 3)), "bad") + + with pytest.raises(ValueError, match="fit inside"): + build_seg_visualization_plan(((8, 8), (segment,))) + + +def _segment(region: CropRegion, mask: torch.Tensor, label: str) -> Segment: + """Create one visualization-ready segment.""" + + return Segment( + cropped_image=None, + cropped_mask=mask, + confidence=0.75, + crop_region=region, + bbox=BoundingBox(*region), + label=label, + ) diff --git a/tests/test_segs_from_sam_output_service.py b/tests/test_segs_from_sam_output_service.py index c88837f..5a0d3ab 100644 --- a/tests/test_segs_from_sam_output_service.py +++ b/tests/test_segs_from_sam_output_service.py @@ -72,7 +72,7 @@ def test_service_downscales_the_segmentation_guide_without_upscaling_source() -> segment = segs[1][0] assert segment.bbox == (64, 32, 128, 64) assert segment.crop_region == (64, 32, 128, 64) - assert cast(torch.Tensor, segment.cropped_mask).shape == (32, 64) + assert cast(torch.Tensor, segment.cropped_mask).shape == (1, 32, 64) assert segment.confidence == 0.8 assert segment.label == "segment_001" @@ -164,7 +164,7 @@ def test_service_expands_retained_mask_crops_to_source_resolution() -> None: segment = result.segs[1][0] assert segment.crop_region == (512, 256, 1024, 512) - assert cast(torch.Tensor, segment.cropped_mask).shape == (256, 512) + assert cast(torch.Tensor, segment.cropped_mask).shape == (1, 256, 512) @pytest.mark.parametrize("resolution", (0, 65, 127)) diff --git a/tests/test_segs_output_service.py b/tests/test_segs_output_service.py index 094ccea..6367f0d 100644 --- a/tests/test_segs_output_service.py +++ b/tests/test_segs_output_service.py @@ -8,7 +8,13 @@ from __future__ import annotations import torch -from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment +from simple_syrup.domain.segs import ( + BoundingBox, + CropRegion, + NativeSegs, + Segment, + coerce_segment_mask, +) from simple_syrup.services.segs_output_service import ( CombinedSegsResult, finalize_detector_segs_output, @@ -79,6 +85,23 @@ def test_finalization_supports_largest_size_for_mask_derived_segs() -> None: assert builder.seen_labels == [["large-low"]] +def test_cropped_mask_coercion_accepts_hw_and_singleton_bhw() -> None: + """Shared mask coercion preserves existing HW and singleton-BHW behavior.""" + + hw_segment = _segment("hw", CropRegion(0, 0, 3, 2), 1.0) + bhw_segment = Segment( + cropped_image=None, + cropped_mask=torch.ones((1, 2, 3)), + confidence=1.0, + crop_region=CropRegion(0, 0, 3, 2), + bbox=BoundingBox(0, 0, 3, 2), + label="bhw", + ) + + assert coerce_segment_mask(hw_segment).shape == (2, 3) + assert coerce_segment_mask(bhw_segment).shape == (2, 3) + + class _RecordingBuilder: """Combined-result builder test double.""" diff --git a/tests/test_simple_preview_segs_node.py b/tests/test_simple_preview_segs_node.py new file mode 100644 index 0000000..3732866 --- /dev/null +++ b/tests/test_simple_preview_segs_node.py @@ -0,0 +1,79 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Simple Preview SEGS ComfyUI node.""" + +from __future__ import annotations + +from typing import ClassVar + +import torch +from pytest import MonkeyPatch + +from simple_syrup.nodes.simple_preview_segs import SimplePreviewSEGS +from simple_syrup.services.simple_preview_segs_service import SegPreviewDocument + + +def test_node_declares_terminal_passthrough_preview_contract() -> None: + """The inspector remains insertable inline and executable as a terminal node.""" + + inputs = SimplePreviewSEGS.INPUT_TYPES()["required"] + + assert tuple(inputs) == ("image", "segs") + assert SimplePreviewSEGS.RETURN_TYPES == ("SEGS",) + assert SimplePreviewSEGS.RETURN_NAMES == ("segs",) + assert SimplePreviewSEGS.OUTPUT_NODE is True + + +def test_node_publishes_manifest_and_returns_original_segs_object( + monkeypatch: MonkeyPatch, +) -> None: + """Preview transport never replaces or mutates the connected SEGS payload.""" + + segs: object = ((4, 5), []) + document = SegPreviewDocument( + source_width=5, + source_height=4, + preview_width=5, + preview_height=4, + image=torch.zeros((1, 4, 5, 3)), + atlas=torch.zeros((1, 1, 1, 3)), + regions=(), + ) + + class _Service: + """Return the fixed preview document.""" + + def build(self, *, image: object, segs: object) -> SegPreviewDocument: + """Validate inputs before returning the document.""" + + assert isinstance(image, torch.Tensor) + assert segs == ((4, 5), ()) + return document + + class _Publisher: + """Return one deterministic frontend manifest.""" + + published: ClassVar[list[SegPreviewDocument]] = [] + + def publish(self, value: SegPreviewDocument) -> dict[str, object]: + """Record and publish the document.""" + + self.published.append(value) + return {"version": 1, "regions": []} + + node = SimplePreviewSEGS() + monkeypatch.setattr(SimplePreviewSEGS, "service_class", _Service) + monkeypatch.setattr(SimplePreviewSEGS, "publisher_class", _Publisher) + + result = node.preview(image=torch.zeros((1, 4, 5, 3)), segs=segs) + + node_result = result["result"] + assert isinstance(node_result, tuple) + assert node_result == (segs,) + assert node_result[0] is segs + assert result["ui"] == { + "simple_syrup_segs_preview": [{"version": 1, "regions": []}] + } + assert _Publisher.published == [document] diff --git a/tests/test_simple_preview_segs_service.py b/tests/test_simple_preview_segs_service.py new file mode 100644 index 0000000..1394302 --- /dev/null +++ b/tests/test_simple_preview_segs_service.py @@ -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 + +"""Tests for compact interactive SEGS preview document creation.""" + +from __future__ import annotations + +import pytest +import torch + +from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment +from simple_syrup.services.simple_preview_segs_service import SimplePreviewSEGSService + + +def test_service_builds_bounded_image_and_non_overlapping_mask_atlas() -> None: + """One compact document retains source geometry and every region mask.""" + + image = torch.rand((1, 1200, 2400, 3), generator=torch.Generator().manual_seed(8)) + first = _segment(CropRegion(0, 0, 600, 400), "subject") + second = _segment(CropRegion(1000, 500, 1600, 1100), "clothing") + + document = SimplePreviewSEGSService().build( + image=image, + segs=((1200, 2400), (first, second)), + ) + + assert (document.preview_width, document.preview_height) == (1024, 512) + assert document.image.shape == (1, 512, 1024, 3) + assert document.atlas.ndim == 4 + assert int(document.atlas.shape[-1]) == 3 + assert [region.label for region in document.regions] == ["subject", "clothing"] + first_atlas, second_atlas = (region.atlas for region in document.regions) + assert ( + first_atlas.left + first_atlas.width <= second_atlas.left + or second_atlas.left + second_atlas.width <= first_atlas.left + or first_atlas.top + first_atlas.height <= second_atlas.top + or second_atlas.top + second_atlas.height <= first_atlas.top + ) + + +def test_service_returns_minimal_assets_for_empty_segs() -> None: + """Empty SEGS remain inspectable without special frontend transport.""" + + document = SimplePreviewSEGSService().build( + image=torch.zeros((1, 8, 12, 3)), + segs=((8, 12), ()), + ) + + assert document.image.shape == (1, 8, 12, 3) + assert document.atlas.shape == (1, 1, 1, 3) + assert document.regions == () + + +def test_service_rejects_image_and_segs_size_mismatch() -> None: + """Hit testing cannot proceed against ambiguous source coordinates.""" + + with pytest.raises(ValueError, match="dimensions to match"): + SimplePreviewSEGSService().build( + image=torch.zeros((1, 8, 8, 3)), + segs=((7, 8), ()), + ) + + +def _segment(region: CropRegion, label: str) -> Segment: + """Create one filled region for atlas tests.""" + + return Segment( + cropped_image=None, + cropped_mask=torch.ones((region.height, region.width)), + confidence=0.8, + crop_region=region, + bbox=BoundingBox(*region), + label=label, + ) diff --git a/web/dist/simple-syrup.js b/web/dist/simple-syrup.js index 43db19a..ef1ae03 100644 --- a/web/dist/simple-syrup.js +++ b/web/dist/simple-syrup.js @@ -895,9 +895,785 @@ function isMaskBatchNode(candidate) { return node.constructor?.comfyClass === LOAD_MASK_BATCH_NODE_ID && typeof node.addWidget === "function"; } +// web/src/interactiveInspector.ts +var InteractiveInspectorController = class { + constructor(view, parse, logger = console) { + this.view = view; + this.parse = parse; + this.logger = logger; + } + view; + parse; + logger; + version = 0; + disposed = false; + committed; + /** Parse and asynchronously display one execution output. */ + update(output) { + if (this.disposed) return; + const version = ++this.version; + let document2; + try { + document2 = this.parse(output); + } catch (error) { + this.fail(error); + return; + } + if (!document2) return; + this.view.setLoading(); + void this.view.prepare(document2).then((prepared) => { + if (this.disposed || version !== this.version) { + prepared.dispose(); + return; + } + this.committed?.dispose(); + this.committed = prepared; + prepared.commit(); + }).catch((error) => { + if (!this.disposed && version === this.version) this.fail(error); + }); + } + /** Release committed state and prevent pending work from publishing. */ + dispose() { + if (this.disposed) return; + this.disposed = true; + this.version += 1; + this.committed?.dispose(); + this.committed = void 0; + this.view.dispose(); + } + fail(error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Could not display Simple Preview SEGS: ${message}`, error); + this.view.showError(message); + } +}; + +// web/src/imageViewport.ts +var ImageViewport = class { + constructor(source, display) { + this.source = source; + this.display = display; + } + source; + display; + /** Convert browser pointer coordinates into source-image coordinates. */ + sourcePoint(clientX, clientY, bounds) { + const normalizedX = clamp((clientX - bounds.left) / Math.max(1, bounds.width)); + const normalizedY = clamp((clientY - bounds.top) / Math.max(1, bounds.height)); + return { + x: normalizedX * this.source.width, + y: normalizedY * this.source.height + }; + } + /** Project one source rectangle into the preview canvas. */ + displayRectangle(sourceRectangle) { + const scaleX = this.display.width / this.source.width; + const scaleY = this.display.height / this.source.height; + return { + x: sourceRectangle.x * scaleX, + y: sourceRectangle.y * scaleY, + width: sourceRectangle.width * scaleX, + height: sourceRectangle.height * scaleY + }; + } +}; +function clamp(value) { + return Math.max(0, Math.min(1, value)); +} + +// web/src/maskAtlas.ts +var MaskAtlas = class { + constructor(document2, pixels) { + this.document = document2; + this.pixels = pixels; + const expected = document2.atlas.width * document2.atlas.height * 4; + if (pixels.length !== expected) { + throw new Error("Simple Preview SEGS atlas pixels do not match its dimensions."); + } + } + document; + pixels; + /** Return every mask containing a source point, most specific first. */ + hitsAt(sourceX, sourceY) { + return this.document.regions.filter((region) => this.contains(region, sourceX, sourceY)).sort((left, right) => left.area - right.area || left.index - right.index); + } + /** Return one colored RGBA image for a packed region mask. */ + coloredMask(region) { + const color = parseColor(region.color); + const data = new Uint8ClampedArray(region.atlas.width * region.atlas.height * 4); + for (let y = 0; y < region.atlas.height; y += 1) { + for (let x = 0; x < region.atlas.width; x += 1) { + const atlasOffset = this.offset(region.atlas.x + x, region.atlas.y + y); + const mask = this.pixels[atlasOffset] ?? 0; + const outputOffset = (y * region.atlas.width + x) * 4; + data[outputOffset] = color[0]; + data[outputOffset + 1] = color[1]; + data[outputOffset + 2] = color[2]; + data[outputOffset + 3] = mask; + } + } + return new ImageData(data, region.atlas.width, region.atlas.height); + } + contains(region, sourceX, sourceY) { + const crop = region.crop; + if (sourceX < crop.x || sourceY < crop.y || sourceX >= crop.x + crop.width || sourceY >= crop.y + crop.height) { + return false; + } + const localX = Math.min( + region.atlas.width - 1, + Math.floor((sourceX - crop.x) / crop.width * region.atlas.width) + ); + const localY = Math.min( + region.atlas.height - 1, + Math.floor((sourceY - crop.y) / crop.height * region.atlas.height) + ); + return (this.pixels[this.offset(region.atlas.x + localX, region.atlas.y + localY)] ?? 0) >= 128; + } + offset(x, y) { + return (y * this.document.atlas.width + x) * 4; + } +}; +function maskAtlasFromImage(previewDocument, image) { + const canvas = globalThis.document.createElement("canvas"); + canvas.width = previewDocument.atlas.width; + canvas.height = previewDocument.atlas.height; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("Simple Preview SEGS could not read its mask atlas."); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + return new MaskAtlas( + previewDocument, + context.getImageData(0, 0, canvas.width, canvas.height).data + ); +} +function parseColor(value) { + return [ + Number.parseInt(value.slice(1, 3), 16), + Number.parseInt(value.slice(3, 5), 16), + Number.parseInt(value.slice(5, 7), 16) + ]; +} + +// web/src/segPreviewTypes.ts +var SEG_PREVIEW_OUTPUT_KEY = "simple_syrup_segs_preview"; +function parseSegPreviewDocument(output) { + if (!isRecord(output)) return void 0; + const candidates = output[SEG_PREVIEW_OUTPUT_KEY]; + if (!Array.isArray(candidates) || candidates.length === 0) return void 0; + const candidate = candidates[candidates.length - 1]; + if (!isRecord(candidate) || candidate.version !== 1) { + throw new Error("Simple Preview SEGS received an unsupported preview payload."); + } + const source = parseDimensions(candidate.source, "source"); + const preview = parseAssetDimensions(candidate.preview, "preview"); + const atlas = parseAssetDimensions(candidate.atlas, "atlas"); + if (!Array.isArray(candidate.regions)) { + throw new Error("Simple Preview SEGS regions must be a list."); + } + const regions = candidate.regions.map(parseRegion); + return { version: 1, source, preview, atlas, regions }; +} +function previewAssetUrl(reference, apiURL = (path) => path) { + const query = new URLSearchParams({ + filename: reference.filename, + subfolder: reference.subfolder, + type: reference.type + }); + return apiURL(`/view?${query.toString()}`); +} +function parseRegion(value, index) { + if (!isRecord(value)) { + throw new Error( + `Simple Preview SEGS region ${String(index + 1)} must be an object.` + ); + } + const id = stringValue(value.id, "region id"); + const label = stringValue(value.label, "region label"); + const color = stringValue(value.color, "region color"); + if (!/^#[0-9a-f]{6}$/i.test(color)) { + throw new Error(`Simple Preview SEGS region '${id}' has an invalid color.`); + } + return { + id, + index: integerValue(value.index, "region index", 0), + label, + confidence: numberValue(value.confidence, "region confidence", 0), + area: integerValue(value.area, "region area", 0), + color, + crop: parseRectangle(value.crop, `region '${id}' crop`), + atlas: parseRectangle(value.atlas, `region '${id}' atlas`) + }; +} +function parseAssetDimensions(value, name) { + if (!isRecord(value)) { + throw new Error(`Simple Preview SEGS ${name} must be an object.`); + } + return { + ...parseDimensions(value, name), + image: parseImageReference(value.image, name) + }; +} +function parseDimensions(value, name) { + if (!isRecord(value)) { + throw new Error(`Simple Preview SEGS ${name} dimensions must be an object.`); + } + return { + width: integerValue(value.width, `${name} width`, 1), + height: integerValue(value.height, `${name} height`, 1) + }; +} +function parseRectangle(value, name) { + if (!isRecord(value)) { + throw new Error(`Simple Preview SEGS ${name} must be an object.`); + } + return { + x: integerValue(value.x, `${name} x`, 0), + y: integerValue(value.y, `${name} y`, 0), + width: integerValue(value.width, `${name} width`, 1), + height: integerValue(value.height, `${name} height`, 1) + }; +} +function parseImageReference(value, name) { + if (!isRecord(value)) { + throw new Error(`Simple Preview SEGS ${name} image reference is invalid.`); + } + const type = stringValue(value.type, `${name} image type`); + if (type !== "input" && type !== "output" && type !== "temp") { + throw new Error(`Simple Preview SEGS ${name} image type is invalid.`); + } + return { + filename: stringValue(value.filename, `${name} image filename`), + subfolder: stringValue(value.subfolder, `${name} image subfolder`), + type + }; +} +function integerValue(value, name, minimum) { + if (!Number.isInteger(value) || value < minimum) { + throw new Error( + `Simple Preview SEGS ${name} must be at least ${String(minimum)}.` + ); + } + return value; +} +function numberValue(value, name, minimum) { + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) { + throw new Error( + `Simple Preview SEGS ${name} must be at least ${String(minimum)}.` + ); + } + return value; +} +function stringValue(value, name) { + if (typeof value !== "string") { + throw new Error(`Simple Preview SEGS ${name} must be text.`); + } + return value; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// web/src/selectionModel.ts +var SelectionModel = class { + hovered = null; + selected = null; + candidates = []; + subscribers = /* @__PURE__ */ new Set(); + /** Subscribe to selection changes and receive the current state immediately. */ + subscribe(subscriber) { + this.subscribers.add(subscriber); + subscriber(this.state()); + return () => this.subscribers.delete(subscriber); + } + /** Replace the hover target and all regions currently beneath the pointer. */ + hover(candidates) { + this.candidates = [...candidates]; + this.hovered = candidates[0] ?? null; + this.publish(); + } + /** Clear transient pointer state without changing the pinned region. */ + clearHover() { + this.candidates = []; + this.hovered = null; + this.publish(); + } + /** Pin one region, or clear the pinned selection with null. */ + select(id) { + this.selected = id; + this.publish(); + } + /** Cycle through overlapping pointer candidates and pin the result. */ + selectNextCandidate() { + if (this.candidates.length === 0) return this.selected; + const currentIndex = this.selected === null ? -1 : this.candidates.indexOf(this.selected); + const next = this.candidates[(currentIndex + 1) % this.candidates.length] ?? null; + this.select(next); + return next; + } + /** Return immutable selection state for rendering or tests. */ + state() { + return { + hovered: this.hovered, + selected: this.selected, + candidates: [...this.candidates], + active: this.hovered ?? this.selected + }; + } + publish() { + const state = this.state(); + for (const subscriber of this.subscribers) subscriber(state); + } +}; + +// web/src/segPreviewInspector.ts +var SegPreviewInspector = class { + constructor(apiURL = (path) => path, loadImage = loadPreviewImage) { + this.apiURL = apiURL; + this.loadImage = loadImage; + installStyles(); + this.element.className = "ss-segs-preview"; + this.body.className = "ss-segs-preview__body"; + this.status.className = "ss-segs-preview__status"; + this.status.setAttribute("aria-live", "polite"); + const toolbar = document.createElement("nav"); + toolbar.className = "ss-segs-preview__toolbar"; + toolbar.setAttribute("aria-label", "SEGS preview mode"); + toolbar.append(this.overlayButton, this.gridButton); + this.overlayButton.addEventListener("click", () => { + this.setMode("overlay"); + }); + this.gridButton.addEventListener("click", () => { + this.setMode("grid"); + }); + this.element.append(toolbar, this.body, this.status); + this.updateModeButtons(); + this.showMessage("Run the workflow to inspect SEGS."); + } + apiURL; + loadImage; + element = document.createElement("section"); + body = document.createElement("div"); + status = document.createElement("div"); + overlayButton = modeButton("Overlay"); + gridButton = modeButton("Grid"); + mode = "overlay"; + committed; + unsubscribeSelection; + highlightCanvas; + focusContainer; + gridButtons = /* @__PURE__ */ new Map(); + /** Show a native loading state while execution assets are decoded. */ + setLoading() { + this.showMessage("Loading SEGS preview\u2026"); + } + /** Load source and atlas assets without publishing partial state. */ + async prepare(document2) { + const [image, atlasImage] = await Promise.all([ + this.loadImage(previewAssetUrl(document2.preview.image, this.apiURL)), + this.loadImage(previewAssetUrl(document2.atlas.image, this.apiURL)) + ]); + const atlas = maskAtlasFromImage(document2, atlasImage); + let disposed = false; + return { + commit: () => { + if (!disposed) this.commit(document2, image, atlas); + }, + dispose: () => { + disposed = true; + } + }; + } + /** Replace the inspector with an actionable failure message. */ + showError(message) { + this.showMessage(message, true); + } + /** Release node-owned DOM and interaction subscriptions. */ + dispose() { + this.unsubscribeSelection?.(); + this.unsubscribeSelection = void 0; + this.committed = void 0; + this.body.replaceChildren(); + this.status.textContent = ""; + } + commit(document2, image, atlas) { + this.unsubscribeSelection?.(); + const selection = new SelectionModel(); + const masks = new Map( + document2.regions.map((region) => [ + region.id, + maskCanvas(atlas, region) + ]) + ); + this.committed = { + document: document2, + image, + atlas, + viewport: new ImageViewport(document2.source, document2.preview), + masks, + selection + }; + this.unsubscribeSelection = selection.subscribe((state) => { + this.renderSelection(state); + }); + this.renderMode(); + } + setMode(mode) { + if (this.mode === mode) return; + this.mode = mode; + this.updateModeButtons(); + this.renderMode(); + } + updateModeButtons() { + this.overlayButton.setAttribute( + "aria-pressed", + String(this.mode === "overlay") + ); + this.gridButton.setAttribute("aria-pressed", String(this.mode === "grid")); + } + renderMode() { + this.highlightCanvas = void 0; + this.focusContainer = void 0; + this.gridButtons.clear(); + if (!this.committed) return; + this.body.replaceChildren( + this.mode === "overlay" ? this.overlayView() : this.gridView() + ); + this.renderSelection(this.committed.selection.state()); + } + overlayView() { + const committed = required(this.committed); + const container = document.createElement("div"); + container.className = "ss-segs-preview__overlay-view"; + const stack = document.createElement("div"); + stack.className = "ss-segs-preview__canvas-stack"; + const base = sizedCanvas(committed.document.preview); + const highlight = sizedCanvas(committed.document.preview); + highlight.className = "ss-segs-preview__highlight"; + this.highlightCanvas = highlight; + const context = context2d(base); + context.drawImage( + committed.image, + 0, + 0, + committed.document.preview.width, + committed.document.preview.height + ); + context.globalAlpha = 0.38; + for (const region of committed.document.regions) { + drawRegionMask(context, committed, region); + } + context.globalAlpha = 1; + highlight.addEventListener("pointermove", (event) => { + const point = committed.viewport.sourcePoint( + event.clientX, + event.clientY, + highlight.getBoundingClientRect() + ); + const hits = committed.atlas.hitsAt(point.x, point.y); + committed.selection.hover(hits.map((region) => region.id)); + }); + highlight.addEventListener("pointerleave", () => { + committed.selection.clearHover(); + }); + highlight.addEventListener("click", () => { + committed.selection.selectNextCandidate(); + }); + stack.append(base, highlight); + this.focusContainer = document.createElement("div"); + this.focusContainer.className = "ss-segs-preview__focus"; + container.append(stack, this.focusContainer); + return container; + } + gridView() { + const committed = required(this.committed); + const grid = document.createElement("div"); + grid.className = "ss-segs-preview__grid"; + grid.setAttribute("role", "listbox"); + if (committed.document.regions.length === 0) { + const empty = document.createElement("div"); + empty.className = "ss-segs-preview__empty"; + empty.textContent = "No SEGS to display."; + grid.append(empty); + return grid; + } + for (const region of committed.document.regions) { + const card = document.createElement("button"); + card.type = "button"; + card.className = "ss-segs-preview__card"; + card.setAttribute("role", "option"); + card.dataset.regionId = region.id; + const canvas = regionPreviewCanvas(committed, region, 160, 132); + const label = document.createElement("span"); + label.textContent = regionTitle(region); + card.append(canvas, label); + card.addEventListener("pointerenter", () => { + committed.selection.hover([region.id]); + }); + card.addEventListener("pointerleave", () => { + committed.selection.clearHover(); + }); + card.addEventListener("click", () => { + committed.selection.select(region.id); + }); + this.gridButtons.set(region.id, card); + grid.append(card); + } + return grid; + } + renderSelection(state) { + const committed = this.committed; + if (!committed) return; + const active = state.active ? committed.document.regions.find((region) => region.id === state.active) : void 0; + if (this.highlightCanvas) { + const context = context2d(this.highlightCanvas); + context.clearRect( + 0, + 0, + this.highlightCanvas.width, + this.highlightCanvas.height + ); + if (active) { + context.save(); + context.globalAlpha = 0.9; + context.shadowColor = "rgba(255, 255, 255, 0.95)"; + context.shadowBlur = 8; + drawRegionMask(context, committed, active); + context.restore(); + } + } + for (const [id, button] of this.gridButtons) { + const selected = id === state.selected; + const highlighted = id === active?.id; + button.setAttribute("aria-selected", String(selected)); + button.dataset.highlighted = String(highlighted); + } + this.renderFocus(active); + if (!active) { + this.status.textContent = `${String(committed.document.regions.length)} regions`; + } else if (state.candidates.length > 1) { + this.status.textContent = `${regionTitle(active)} \xB7 ${String(state.candidates.length)} overlapping regions \xB7 click to cycle`; + } else { + this.status.textContent = regionTitle(active); + } + } + renderFocus(region) { + if (!this.focusContainer || !this.committed) return; + if (!region) { + this.focusContainer.replaceChildren(); + return; + } + const title = document.createElement("strong"); + title.textContent = regionTitle(region); + const details = document.createElement("span"); + details.textContent = `${String(Math.round(region.confidence * 100))}% confidence \xB7 ${region.area.toLocaleString()} px`; + this.focusContainer.replaceChildren( + regionPreviewCanvas(this.committed, region, 320, 220), + title, + details + ); + } + showMessage(message, error = false) { + const content = document.createElement("div"); + content.className = "ss-segs-preview__message"; + content.dataset.error = String(error); + content.textContent = message; + this.body.replaceChildren(content); + this.status.textContent = ""; + } +}; +function drawRegionMask(context, committed, region) { + const placement = committed.viewport.displayRectangle(region.crop); + const mask = committed.masks.get(region.id); + if (!mask) return; + context.drawImage( + mask, + placement.x, + placement.y, + placement.width, + placement.height + ); +} +function regionPreviewCanvas(committed, region, maximumWidth, maximumHeight) { + const aspect = region.crop.width / region.crop.height; + const width = Math.max(80, Math.min(maximumWidth, Math.round(maximumHeight * aspect))); + const height = Math.max(64, Math.min(maximumHeight, Math.round(width / aspect))); + const canvas = sizedCanvas({ width, height }); + const context = context2d(canvas); + const source = committed.viewport.displayRectangle(region.crop); + context.drawImage( + committed.image, + source.x, + source.y, + source.width, + source.height, + 0, + 0, + width, + height + ); + context.globalAlpha = 0.45; + const mask = committed.masks.get(region.id); + if (mask) context.drawImage(mask, 0, 0, width, height); + context.globalAlpha = 1; + return canvas; +} +function maskCanvas(atlas, region) { + const canvas = sizedCanvas(region.atlas); + context2d(canvas).putImageData(atlas.coloredMask(region), 0, 0); + return canvas; +} +function sizedCanvas(size) { + const canvas = document.createElement("canvas"); + canvas.width = size.width; + canvas.height = size.height; + return canvas; +} +function context2d(canvas) { + const context = canvas.getContext("2d"); + if (!context) throw new Error("Simple Preview SEGS requires canvas rendering."); + return context; +} +function regionTitle(region) { + const label = region.label.trim(); + return `${String(region.index + 1)}. ${label || "region"}`; +} +function modeButton(label) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + return button; +} +function required(value) { + if (!value) throw new Error("Simple Preview SEGS has no committed document."); + return value; +} +function loadPreviewImage(url) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => { + resolve(image); + }, { once: true }); + image.addEventListener( + "error", + () => { + reject(new Error("Simple Preview SEGS could not load a preview asset.")); + }, + { once: true } + ); + image.src = url; + }); +} +var stylesInstalled = false; +function installStyles() { + if (stylesInstalled) return; + const style = document.createElement("style"); + style.dataset.simpleSyrupSegsPreview = "true"; + style.textContent = ` + .ss-segs-preview { box-sizing: border-box; width: 100%; min-height: 360px; color: var(--fg-color, #ddd); font: 12px sans-serif; } + .ss-segs-preview * { box-sizing: border-box; } + .ss-segs-preview__toolbar { display: flex; gap: 4px; margin: 0 0 6px; } + .ss-segs-preview__toolbar button { flex: 1; min-height: 26px; border: 1px solid var(--border-color, #555); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; } + .ss-segs-preview__toolbar button[aria-pressed="true"] { border-color: var(--p-primary-color, #6aa9ff); background: color-mix(in srgb, var(--p-primary-color, #6aa9ff) 28%, var(--comfy-input-bg, #222)); } + .ss-segs-preview__body { min-height: 320px; overflow: hidden; border: 1px solid var(--border-color, #444); border-radius: 6px; background: var(--comfy-menu-bg, #181818); } + .ss-segs-preview__canvas-stack { position: relative; line-height: 0; background: #111; } + .ss-segs-preview__canvas-stack canvas { display: block; width: 100%; height: auto; } + .ss-segs-preview__highlight { position: absolute; inset: 0; cursor: crosshair; } + .ss-segs-preview__status { min-height: 22px; padding: 5px 2px 0; color: var(--descrip-text, #aaa); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .ss-segs-preview__focus { display: grid; grid-template-columns: minmax(96px, 42%) 1fr; gap: 3px 9px; align-items: start; padding: 7px; border-top: 1px solid var(--border-color, #444); } + .ss-segs-preview__focus canvas { grid-row: 1 / span 2; width: 100%; height: auto; border-radius: 4px; background: #111; } + .ss-segs-preview__focus span { color: var(--descrip-text, #aaa); } + .ss-segs-preview__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 6px; max-height: 430px; padding: 7px; overflow: auto; } + .ss-segs-preview__card { min-width: 0; padding: 4px; border: 1px solid var(--border-color, #444); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; text-align: left; } + .ss-segs-preview__card[data-highlighted="true"], .ss-segs-preview__card[aria-selected="true"] { border-color: var(--p-primary-color, #6aa9ff); box-shadow: 0 0 0 1px var(--p-primary-color, #6aa9ff); } + .ss-segs-preview__card canvas { display: block; width: 100%; height: 94px; object-fit: contain; margin-bottom: 4px; background: #111; } + .ss-segs-preview__card span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ss-segs-preview__message, .ss-segs-preview__empty { display: grid; min-height: 320px; place-items: center; padding: 18px; color: var(--descrip-text, #aaa); text-align: center; } + .ss-segs-preview__message[data-error="true"] { color: var(--error-text, #ff8a80); } + `; + document.head.append(style); + stylesInstalled = true; +} + +// web/src/segPreviewNode.ts +var SIMPLE_PREVIEW_SEGS_NODE_ID = "SimpleSyrup.SimplePreviewSEGS"; +function registerSimplePreviewSEGS(app2, api, loadImage, logger = console) { + const controllers = /* @__PURE__ */ new Map(); + const extension = { + name: "SimpleSyrup.SimplePreviewSEGS", + nodeCreated(candidate) { + if (!isPreviewNode(candidate)) return; + const inspector = new SegPreviewInspector( + (path) => api.apiURL?.(path) ?? path, + loadImage + ); + const controller = new InteractiveInspectorController( + inspector, + parseSegPreviewDocument, + logger + ); + const widget = candidate.addDOMWidget( + "simple_syrup_segs_preview", + "simple_syrup_segs_preview", + inspector.element, + { serialize: false, canvasOnly: false } + ); + widget.serialize = false; + widget.options ??= {}; + widget.options.serialize = false; + widget.options.canvasOnly = false; + widget.computeSize = (width = 420) => [Math.max(360, width), 470]; + const registerId = () => { + if (candidate.id !== void 0) { + controllers.set(String(candidate.id), controller); + } + }; + registerId(); + const originalExecuted = candidate.onExecuted; + candidate.onExecuted = function(output) { + originalExecuted?.call(this, output); + controller.update(output); + }; + const originalGraphConfigured = candidate.onGraphConfigured; + candidate.onGraphConfigured = function(...args) { + const result = originalGraphConfigured?.apply(this, args); + registerId(); + if (candidate.id !== void 0) { + controller.update(app2.nodeOutputs?.[String(candidate.id)]); + } + return result; + }; + const originalRemoved = candidate.onRemoved; + candidate.onRemoved = function(...args) { + if (candidate.id !== void 0) controllers.delete(String(candidate.id)); + controller.dispose(); + return originalRemoved?.apply(this, args); + }; + const computed = candidate.computeSize?.(); + const current = candidate.size ?? computed; + if (current && candidate.setSize) { + candidate.setSize([ + Math.max(420, current[0]), + Math.max(520, computed?.[1] ?? current[1]) + ]); + } + }, + onNodeOutputsUpdated(outputs) { + for (const [nodeId, output] of Object.entries(outputs)) { + controllers.get(nodeId)?.update(output); + } + } + }; + app2.registerExtension(extension); +} +function isPreviewNode(value) { + if (typeof value !== "object" || value === null) return false; + const node = value; + return node.constructor?.comfyClass === SIMPLE_PREVIEW_SEGS_NODE_ID && typeof node.addDOMWidget === "function"; +} + // web/src/main.ts var comfyApp = app; -var comfyExecutionEvents = window.comfyAPI.api.api; +var comfyApi = window.comfyAPI.api.api; comfyApp.registerExtension({ name: "SimpleSyrup.Settings", async setup(appInstance) { @@ -905,4 +1681,5 @@ comfyApp.registerExtension({ registerExternalLLMRefreshHook(appInstance); } }); -registerMaskBatchUpload(comfyApp, comfyExecutionEvents); +registerMaskBatchUpload(comfyApp, comfyApi); +registerSimplePreviewSEGS(comfyApp, comfyApi); diff --git a/web/src/imageViewport.ts b/web/src/imageViewport.ts new file mode 100644 index 0000000..2b369f9 --- /dev/null +++ b/web/src/imageViewport.ts @@ -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 + +import type { Dimensions, Rectangle } from "./segPreviewTypes"; + +export interface Point { + x: number; + y: number; +} + +/** Map pointer and source geometry into a displayed image viewport. */ +export class ImageViewport { + constructor( + private readonly source: Dimensions, + private readonly display: Dimensions + ) {} + + /** Convert browser pointer coordinates into source-image coordinates. */ + sourcePoint(clientX: number, clientY: number, bounds: DOMRect): Point { + const normalizedX = clamp((clientX - bounds.left) / Math.max(1, bounds.width)); + const normalizedY = clamp((clientY - bounds.top) / Math.max(1, bounds.height)); + return { + x: normalizedX * this.source.width, + y: normalizedY * this.source.height + }; + } + + /** Project one source rectangle into the preview canvas. */ + displayRectangle(sourceRectangle: Rectangle): Rectangle { + const scaleX = this.display.width / this.source.width; + const scaleY = this.display.height / this.source.height; + return { + x: sourceRectangle.x * scaleX, + y: sourceRectangle.y * scaleY, + width: sourceRectangle.width * scaleX, + height: sourceRectangle.height * scaleY + }; + } +} + +function clamp(value: number): number { + return Math.max(0, Math.min(1, value)); +} diff --git a/web/src/interactiveInspector.ts b/web/src/interactiveInspector.ts new file mode 100644 index 0000000..9a0f8be --- /dev/null +++ b/web/src/interactiveInspector.ts @@ -0,0 +1,76 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { Logger } from "./types"; + +export interface PreparedInspectorState { + commit(): void; + dispose(): void; +} + +export interface AsyncInspectorView { + readonly element: HTMLElement; + setLoading(): void; + prepare(document: TDocument): Promise; + showError(message: string): void; + dispose(): void; +} + +/** Publish only the latest asynchronously prepared execution document. */ +export class InteractiveInspectorController { + private version = 0; + private disposed = false; + private committed: PreparedInspectorState | undefined; + + constructor( + private readonly view: AsyncInspectorView, + private readonly parse: (output: unknown) => TDocument | undefined, + private readonly logger: Logger = console + ) {} + + /** Parse and asynchronously display one execution output. */ + update(output: unknown): void { + if (this.disposed) return; + const version = ++this.version; + let document: TDocument | undefined; + try { + document = this.parse(output); + } catch (error: unknown) { + this.fail(error); + return; + } + if (!document) return; + this.view.setLoading(); + void this.view + .prepare(document) + .then((prepared) => { + if (this.disposed || version !== this.version) { + prepared.dispose(); + return; + } + this.committed?.dispose(); + this.committed = prepared; + prepared.commit(); + }) + .catch((error: unknown) => { + if (!this.disposed && version === this.version) this.fail(error); + }); + } + + /** Release committed state and prevent pending work from publishing. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.version += 1; + this.committed?.dispose(); + this.committed = undefined; + this.view.dispose(); + } + + private fail(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Could not display Simple Preview SEGS: ${message}`, error); + this.view.showError(message); + } +} diff --git a/web/src/main.ts b/web/src/main.ts index 8f62569..742dcc3 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -8,14 +8,15 @@ import { app } from "../../../scripts/app.js"; import { registerSimpleSyrupSettings } from "./settings"; import { registerExternalLLMRefreshHook } from "./refresh"; import { registerMaskBatchUpload } from "./maskBatchUpload"; -import type { ComfyApp, ComfyExecutionEvents } from "./types"; +import { registerSimplePreviewSEGS } from "./segPreviewNode"; +import type { ComfyApi, ComfyApp } from "./types"; interface ComfyRuntimeWindow extends Window { - comfyAPI: { api: { api: ComfyExecutionEvents } }; + comfyAPI: { api: { api: ComfyApi } }; } const comfyApp = app as unknown as ComfyApp; -const comfyExecutionEvents = (window as unknown as ComfyRuntimeWindow).comfyAPI +const comfyApi = (window as unknown as ComfyRuntimeWindow).comfyAPI .api.api; comfyApp.registerExtension({ @@ -26,4 +27,5 @@ comfyApp.registerExtension({ } }); -registerMaskBatchUpload(comfyApp, comfyExecutionEvents); +registerMaskBatchUpload(comfyApp, comfyApi); +registerSimplePreviewSEGS(comfyApp, comfyApi); diff --git a/web/src/maskAtlas.ts b/web/src/maskAtlas.ts new file mode 100644 index 0000000..886bc8f --- /dev/null +++ b/web/src/maskAtlas.ts @@ -0,0 +1,100 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { + SegPreviewDocument, + SegPreviewRegion +} from "./segPreviewTypes"; + +/** Provide mask sampling and RGBA extraction from one packed atlas image. */ +export class MaskAtlas { + constructor( + private readonly document: SegPreviewDocument, + private readonly pixels: Uint8ClampedArray + ) { + const expected = document.atlas.width * document.atlas.height * 4; + if (pixels.length !== expected) { + throw new Error("Simple Preview SEGS atlas pixels do not match its dimensions."); + } + } + + /** Return every mask containing a source point, most specific first. */ + hitsAt(sourceX: number, sourceY: number): SegPreviewRegion[] { + return this.document.regions + .filter((region) => this.contains(region, sourceX, sourceY)) + .sort((left, right) => left.area - right.area || left.index - right.index); + } + + /** Return one colored RGBA image for a packed region mask. */ + coloredMask(region: SegPreviewRegion): ImageData { + const color = parseColor(region.color); + const data = new Uint8ClampedArray(region.atlas.width * region.atlas.height * 4); + for (let y = 0; y < region.atlas.height; y += 1) { + for (let x = 0; x < region.atlas.width; x += 1) { + const atlasOffset = this.offset(region.atlas.x + x, region.atlas.y + y); + const mask = this.pixels[atlasOffset] ?? 0; + const outputOffset = (y * region.atlas.width + x) * 4; + data[outputOffset] = color[0]; + data[outputOffset + 1] = color[1]; + data[outputOffset + 2] = color[2]; + data[outputOffset + 3] = mask; + } + } + return new ImageData(data, region.atlas.width, region.atlas.height); + } + + private contains( + region: SegPreviewRegion, + sourceX: number, + sourceY: number + ): boolean { + const crop = region.crop; + if ( + sourceX < crop.x || + sourceY < crop.y || + sourceX >= crop.x + crop.width || + sourceY >= crop.y + crop.height + ) { + return false; + } + const localX = Math.min( + region.atlas.width - 1, + Math.floor(((sourceX - crop.x) / crop.width) * region.atlas.width) + ); + const localY = Math.min( + region.atlas.height - 1, + Math.floor(((sourceY - crop.y) / crop.height) * region.atlas.height) + ); + return (this.pixels[this.offset(region.atlas.x + localX, region.atlas.y + localY)] ?? 0) >= 128; + } + + private offset(x: number, y: number): number { + return (y * this.document.atlas.width + x) * 4; + } +} + +/** Read one loaded atlas image into deterministic hit-test pixels. */ +export function maskAtlasFromImage( + previewDocument: SegPreviewDocument, + image: CanvasImageSource +): MaskAtlas { + const canvas = globalThis.document.createElement("canvas"); + canvas.width = previewDocument.atlas.width; + canvas.height = previewDocument.atlas.height; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("Simple Preview SEGS could not read its mask atlas."); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + return new MaskAtlas( + previewDocument, + context.getImageData(0, 0, canvas.width, canvas.height).data + ); +} + +function parseColor(value: string): [number, number, number] { + return [ + Number.parseInt(value.slice(1, 3), 16), + Number.parseInt(value.slice(3, 5), 16), + Number.parseInt(value.slice(5, 7), 16) + ]; +} diff --git a/web/src/segPreviewInspector.ts b/web/src/segPreviewInspector.ts new file mode 100644 index 0000000..b66c81a --- /dev/null +++ b/web/src/segPreviewInspector.ts @@ -0,0 +1,439 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type AsyncInspectorView, + type PreparedInspectorState +} from "./interactiveInspector"; +import { ImageViewport } from "./imageViewport"; +import { maskAtlasFromImage } from "./maskAtlas"; +import type { MaskAtlas } from "./maskAtlas"; +import type { + SegPreviewDocument, + SegPreviewRegion +} from "./segPreviewTypes"; +import { previewAssetUrl } from "./segPreviewTypes"; +import { SelectionModel, type SelectionState } from "./selectionModel"; + +type PreviewMode = "overlay" | "grid"; +export type PreviewImageLoader = (url: string) => Promise; + +interface CommittedPreview { + document: SegPreviewDocument; + image: HTMLImageElement; + atlas: MaskAtlas; + viewport: ImageViewport; + masks: Map; + selection: SelectionModel; +} + +/** Render the interactive SEGS inspector inside Comfy's native DOM widget. */ +export class SegPreviewInspector + implements AsyncInspectorView +{ + readonly element = document.createElement("section"); + private readonly body = document.createElement("div"); + private readonly status = document.createElement("div"); + private readonly overlayButton = modeButton("Overlay"); + private readonly gridButton = modeButton("Grid"); + private mode: PreviewMode = "overlay"; + private committed: CommittedPreview | undefined; + private unsubscribeSelection: (() => void) | undefined; + private highlightCanvas: HTMLCanvasElement | undefined; + private focusContainer: HTMLElement | undefined; + private gridButtons = new Map(); + + constructor( + private readonly apiURL: (path: string) => string = (path) => path, + private readonly loadImage: PreviewImageLoader = loadPreviewImage + ) { + installStyles(); + this.element.className = "ss-segs-preview"; + this.body.className = "ss-segs-preview__body"; + this.status.className = "ss-segs-preview__status"; + this.status.setAttribute("aria-live", "polite"); + const toolbar = document.createElement("nav"); + toolbar.className = "ss-segs-preview__toolbar"; + toolbar.setAttribute("aria-label", "SEGS preview mode"); + toolbar.append(this.overlayButton, this.gridButton); + this.overlayButton.addEventListener("click", () => { + this.setMode("overlay"); + }); + this.gridButton.addEventListener("click", () => { + this.setMode("grid"); + }); + this.element.append(toolbar, this.body, this.status); + this.updateModeButtons(); + this.showMessage("Run the workflow to inspect SEGS."); + } + + /** Show a native loading state while execution assets are decoded. */ + setLoading(): void { + this.showMessage("Loading SEGS preview…"); + } + + /** Load source and atlas assets without publishing partial state. */ + async prepare(document: SegPreviewDocument): Promise { + const [image, atlasImage] = await Promise.all([ + this.loadImage(previewAssetUrl(document.preview.image, this.apiURL)), + this.loadImage(previewAssetUrl(document.atlas.image, this.apiURL)) + ]); + const atlas = maskAtlasFromImage(document, atlasImage); + let disposed = false; + return { + commit: () => { + if (!disposed) this.commit(document, image, atlas); + }, + dispose: () => { + disposed = true; + } + }; + } + + /** Replace the inspector with an actionable failure message. */ + showError(message: string): void { + this.showMessage(message, true); + } + + /** Release node-owned DOM and interaction subscriptions. */ + dispose(): void { + this.unsubscribeSelection?.(); + this.unsubscribeSelection = undefined; + this.committed = undefined; + this.body.replaceChildren(); + this.status.textContent = ""; + } + + private commit( + document: SegPreviewDocument, + image: HTMLImageElement, + atlas: MaskAtlas + ): void { + this.unsubscribeSelection?.(); + const selection = new SelectionModel(); + const masks = new Map( + document.regions.map((region) => [ + region.id, + maskCanvas(atlas, region) + ]) + ); + this.committed = { + document, + image, + atlas, + viewport: new ImageViewport(document.source, document.preview), + masks, + selection + }; + this.unsubscribeSelection = selection.subscribe((state) => { + this.renderSelection(state); + }); + this.renderMode(); + } + + private setMode(mode: PreviewMode): void { + if (this.mode === mode) return; + this.mode = mode; + this.updateModeButtons(); + this.renderMode(); + } + + private updateModeButtons(): void { + this.overlayButton.setAttribute( + "aria-pressed", + String(this.mode === "overlay") + ); + this.gridButton.setAttribute("aria-pressed", String(this.mode === "grid")); + } + + private renderMode(): void { + this.highlightCanvas = undefined; + this.focusContainer = undefined; + this.gridButtons.clear(); + if (!this.committed) return; + this.body.replaceChildren( + this.mode === "overlay" ? this.overlayView() : this.gridView() + ); + this.renderSelection(this.committed.selection.state()); + } + + private overlayView(): HTMLElement { + const committed = required(this.committed); + const container = document.createElement("div"); + container.className = "ss-segs-preview__overlay-view"; + const stack = document.createElement("div"); + stack.className = "ss-segs-preview__canvas-stack"; + const base = sizedCanvas(committed.document.preview); + const highlight = sizedCanvas(committed.document.preview); + highlight.className = "ss-segs-preview__highlight"; + this.highlightCanvas = highlight; + const context = context2d(base); + context.drawImage( + committed.image, + 0, + 0, + committed.document.preview.width, + committed.document.preview.height + ); + context.globalAlpha = 0.38; + for (const region of committed.document.regions) { + drawRegionMask(context, committed, region); + } + context.globalAlpha = 1; + highlight.addEventListener("pointermove", (event) => { + const point = committed.viewport.sourcePoint( + event.clientX, + event.clientY, + highlight.getBoundingClientRect() + ); + const hits = committed.atlas.hitsAt(point.x, point.y); + committed.selection.hover(hits.map((region) => region.id)); + }); + highlight.addEventListener("pointerleave", () => { + committed.selection.clearHover(); + }); + highlight.addEventListener("click", () => { + committed.selection.selectNextCandidate(); + }); + stack.append(base, highlight); + this.focusContainer = document.createElement("div"); + this.focusContainer.className = "ss-segs-preview__focus"; + container.append(stack, this.focusContainer); + return container; + } + + private gridView(): HTMLElement { + const committed = required(this.committed); + const grid = document.createElement("div"); + grid.className = "ss-segs-preview__grid"; + grid.setAttribute("role", "listbox"); + if (committed.document.regions.length === 0) { + const empty = document.createElement("div"); + empty.className = "ss-segs-preview__empty"; + empty.textContent = "No SEGS to display."; + grid.append(empty); + return grid; + } + for (const region of committed.document.regions) { + const card = document.createElement("button"); + card.type = "button"; + card.className = "ss-segs-preview__card"; + card.setAttribute("role", "option"); + card.dataset.regionId = region.id; + const canvas = regionPreviewCanvas(committed, region, 160, 132); + const label = document.createElement("span"); + label.textContent = regionTitle(region); + card.append(canvas, label); + card.addEventListener("pointerenter", () => { + committed.selection.hover([region.id]); + }); + card.addEventListener("pointerleave", () => { + committed.selection.clearHover(); + }); + card.addEventListener("click", () => { + committed.selection.select(region.id); + }); + this.gridButtons.set(region.id, card); + grid.append(card); + } + return grid; + } + + private renderSelection(state: SelectionState): void { + const committed = this.committed; + if (!committed) return; + const active = state.active + ? committed.document.regions.find((region) => region.id === state.active) + : undefined; + if (this.highlightCanvas) { + const context = context2d(this.highlightCanvas); + context.clearRect( + 0, + 0, + this.highlightCanvas.width, + this.highlightCanvas.height + ); + if (active) { + context.save(); + context.globalAlpha = 0.9; + context.shadowColor = "rgba(255, 255, 255, 0.95)"; + context.shadowBlur = 8; + drawRegionMask(context, committed, active); + context.restore(); + } + } + for (const [id, button] of this.gridButtons) { + const selected = id === state.selected; + const highlighted = id === active?.id; + button.setAttribute("aria-selected", String(selected)); + button.dataset.highlighted = String(highlighted); + } + this.renderFocus(active); + if (!active) { + this.status.textContent = `${String(committed.document.regions.length)} regions`; + } else if (state.candidates.length > 1) { + this.status.textContent = `${regionTitle(active)} · ${String(state.candidates.length)} overlapping regions · click to cycle`; + } else { + this.status.textContent = regionTitle(active); + } + } + + private renderFocus(region: SegPreviewRegion | undefined): void { + if (!this.focusContainer || !this.committed) return; + if (!region) { + this.focusContainer.replaceChildren(); + return; + } + const title = document.createElement("strong"); + title.textContent = regionTitle(region); + const details = document.createElement("span"); + details.textContent = `${String(Math.round(region.confidence * 100))}% confidence · ${region.area.toLocaleString()} px`; + this.focusContainer.replaceChildren( + regionPreviewCanvas(this.committed, region, 320, 220), + title, + details + ); + } + + private showMessage(message: string, error = false): void { + const content = document.createElement("div"); + content.className = "ss-segs-preview__message"; + content.dataset.error = String(error); + content.textContent = message; + this.body.replaceChildren(content); + this.status.textContent = ""; + } +} + +function drawRegionMask( + context: CanvasRenderingContext2D, + committed: CommittedPreview, + region: SegPreviewRegion +): void { + const placement = committed.viewport.displayRectangle(region.crop); + const mask = committed.masks.get(region.id); + if (!mask) return; + context.drawImage( + mask, + placement.x, + placement.y, + placement.width, + placement.height + ); +} + +function regionPreviewCanvas( + committed: CommittedPreview, + region: SegPreviewRegion, + maximumWidth: number, + maximumHeight: number +): HTMLCanvasElement { + const aspect = region.crop.width / region.crop.height; + const width = Math.max(80, Math.min(maximumWidth, Math.round(maximumHeight * aspect))); + const height = Math.max(64, Math.min(maximumHeight, Math.round(width / aspect))); + const canvas = sizedCanvas({ width, height }); + const context = context2d(canvas); + const source = committed.viewport.displayRectangle(region.crop); + context.drawImage( + committed.image, + source.x, + source.y, + source.width, + source.height, + 0, + 0, + width, + height + ); + context.globalAlpha = 0.45; + const mask = committed.masks.get(region.id); + if (mask) context.drawImage(mask, 0, 0, width, height); + context.globalAlpha = 1; + return canvas; +} + +function maskCanvas(atlas: MaskAtlas, region: SegPreviewRegion): HTMLCanvasElement { + const canvas = sizedCanvas(region.atlas); + context2d(canvas).putImageData(atlas.coloredMask(region), 0, 0); + return canvas; +} + +function sizedCanvas(size: { width: number; height: number }): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = size.width; + canvas.height = size.height; + return canvas; +} + +function context2d(canvas: HTMLCanvasElement): CanvasRenderingContext2D { + const context = canvas.getContext("2d"); + if (!context) throw new Error("Simple Preview SEGS requires canvas rendering."); + return context; +} + +function regionTitle(region: SegPreviewRegion): string { + const label = region.label.trim(); + return `${String(region.index + 1)}. ${label || "region"}`; +} + +function modeButton(label: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + return button; +} + +function required(value: T | undefined): T { + if (!value) throw new Error("Simple Preview SEGS has no committed document."); + return value; +} + +function loadPreviewImage(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => { + resolve(image); + }, { once: true }); + image.addEventListener( + "error", + () => { + reject(new Error("Simple Preview SEGS could not load a preview asset.")); + }, + { once: true } + ); + image.src = url; + }); +} + +let stylesInstalled = false; + +function installStyles(): void { + if (stylesInstalled) return; + const style = document.createElement("style"); + style.dataset.simpleSyrupSegsPreview = "true"; + style.textContent = ` + .ss-segs-preview { box-sizing: border-box; width: 100%; min-height: 360px; color: var(--fg-color, #ddd); font: 12px sans-serif; } + .ss-segs-preview * { box-sizing: border-box; } + .ss-segs-preview__toolbar { display: flex; gap: 4px; margin: 0 0 6px; } + .ss-segs-preview__toolbar button { flex: 1; min-height: 26px; border: 1px solid var(--border-color, #555); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; } + .ss-segs-preview__toolbar button[aria-pressed="true"] { border-color: var(--p-primary-color, #6aa9ff); background: color-mix(in srgb, var(--p-primary-color, #6aa9ff) 28%, var(--comfy-input-bg, #222)); } + .ss-segs-preview__body { min-height: 320px; overflow: hidden; border: 1px solid var(--border-color, #444); border-radius: 6px; background: var(--comfy-menu-bg, #181818); } + .ss-segs-preview__canvas-stack { position: relative; line-height: 0; background: #111; } + .ss-segs-preview__canvas-stack canvas { display: block; width: 100%; height: auto; } + .ss-segs-preview__highlight { position: absolute; inset: 0; cursor: crosshair; } + .ss-segs-preview__status { min-height: 22px; padding: 5px 2px 0; color: var(--descrip-text, #aaa); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .ss-segs-preview__focus { display: grid; grid-template-columns: minmax(96px, 42%) 1fr; gap: 3px 9px; align-items: start; padding: 7px; border-top: 1px solid var(--border-color, #444); } + .ss-segs-preview__focus canvas { grid-row: 1 / span 2; width: 100%; height: auto; border-radius: 4px; background: #111; } + .ss-segs-preview__focus span { color: var(--descrip-text, #aaa); } + .ss-segs-preview__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 6px; max-height: 430px; padding: 7px; overflow: auto; } + .ss-segs-preview__card { min-width: 0; padding: 4px; border: 1px solid var(--border-color, #444); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; text-align: left; } + .ss-segs-preview__card[data-highlighted="true"], .ss-segs-preview__card[aria-selected="true"] { border-color: var(--p-primary-color, #6aa9ff); box-shadow: 0 0 0 1px var(--p-primary-color, #6aa9ff); } + .ss-segs-preview__card canvas { display: block; width: 100%; height: 94px; object-fit: contain; margin-bottom: 4px; background: #111; } + .ss-segs-preview__card span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ss-segs-preview__message, .ss-segs-preview__empty { display: grid; min-height: 320px; place-items: center; padding: 18px; color: var(--descrip-text, #aaa); text-align: center; } + .ss-segs-preview__message[data-error="true"] { color: var(--error-text, #ff8a80); } + `; + document.head.append(style); + stylesInstalled = true; +} diff --git a/web/src/segPreviewNode.ts b/web/src/segPreviewNode.ts new file mode 100644 index 0000000..744ad75 --- /dev/null +++ b/web/src/segPreviewNode.ts @@ -0,0 +1,139 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { InteractiveInspectorController } from "./interactiveInspector"; +import { SegPreviewInspector, type PreviewImageLoader } from "./segPreviewInspector"; +import { + parseSegPreviewDocument, + type SegPreviewDocument +} from "./segPreviewTypes"; +import type { + ComfyApi, + ComfyApp, + ComfyExtension, + ComfyNodeExecutionOutput, + Logger +} from "./types"; + +const SIMPLE_PREVIEW_SEGS_NODE_ID = "SimpleSyrup.SimplePreviewSEGS"; + +interface DomWidget { + serialize?: boolean; + computeSize?: (width?: number) => [number, number]; + options?: { + serialize?: boolean; + canvasOnly?: boolean; + }; +} + +interface PreviewNode { + constructor: { comfyClass?: string }; + id?: string | number; + size?: [number, number]; + onExecuted?: (output: unknown) => void; + onGraphConfigured?: (...args: unknown[]) => unknown; + onRemoved?: (...args: unknown[]) => unknown; + computeSize?: () => [number, number]; + setSize?: (size: [number, number]) => void; + addDOMWidget( + name: string, + type: string, + element: HTMLElement, + options?: DomWidget["options"] + ): DomWidget; +} + +/** Register the renderer-neutral interactive SEGS inspector node widget. */ +export function registerSimplePreviewSEGS( + app: ComfyApp, + api: ComfyApi, + loadImage?: PreviewImageLoader, + logger: Logger = console +): void { + const controllers = new Map< + string, + InteractiveInspectorController + >(); + + const extension: ComfyExtension = { + name: "SimpleSyrup.SimplePreviewSEGS", + nodeCreated(candidate: unknown) { + if (!isPreviewNode(candidate)) return; + const inspector = new SegPreviewInspector( + (path) => api.apiURL?.(path) ?? path, + loadImage + ); + const controller = new InteractiveInspectorController( + inspector, + parseSegPreviewDocument, + logger + ); + const widget = candidate.addDOMWidget( + "simple_syrup_segs_preview", + "simple_syrup_segs_preview", + inspector.element, + { serialize: false, canvasOnly: false } + ); + widget.serialize = false; + widget.options ??= {}; + widget.options.serialize = false; + widget.options.canvasOnly = false; + widget.computeSize = (width = 420) => [Math.max(360, width), 470]; + + const registerId = (): void => { + if (candidate.id !== undefined) { + controllers.set(String(candidate.id), controller); + } + }; + registerId(); + + const originalExecuted = candidate.onExecuted; + candidate.onExecuted = function (output: unknown): void { + originalExecuted?.call(this, output); + controller.update(output); + }; + + const originalGraphConfigured = candidate.onGraphConfigured; + candidate.onGraphConfigured = function (...args: unknown[]): unknown { + const result = originalGraphConfigured?.apply(this, args); + registerId(); + if (candidate.id !== undefined) { + controller.update(app.nodeOutputs?.[String(candidate.id)]); + } + return result; + }; + + const originalRemoved = candidate.onRemoved; + candidate.onRemoved = function (...args: unknown[]): unknown { + if (candidate.id !== undefined) controllers.delete(String(candidate.id)); + controller.dispose(); + return originalRemoved?.apply(this, args); + }; + + const computed = candidate.computeSize?.(); + const current = candidate.size ?? computed; + if (current && candidate.setSize) { + candidate.setSize([ + Math.max(420, current[0]), + Math.max(520, computed?.[1] ?? current[1]) + ]); + } + }, + onNodeOutputsUpdated(outputs: Record) { + for (const [nodeId, output] of Object.entries(outputs)) { + controllers.get(nodeId)?.update(output); + } + } + }; + app.registerExtension(extension); +} + +function isPreviewNode(value: unknown): value is PreviewNode { + if (typeof value !== "object" || value === null) return false; + const node = value as Partial; + return ( + node.constructor?.comfyClass === SIMPLE_PREVIEW_SEGS_NODE_ID && + typeof node.addDOMWidget === "function" + ); +} diff --git a/web/src/segPreviewTypes.ts b/web/src/segPreviewTypes.ts new file mode 100644 index 0000000..4c1362e --- /dev/null +++ b/web/src/segPreviewTypes.ts @@ -0,0 +1,181 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { ComfyImageResult, ComfyNodeExecutionOutput } from "./types"; + +export const SEG_PREVIEW_OUTPUT_KEY = "simple_syrup_segs_preview"; + +export interface Dimensions { + width: number; + height: number; +} + +export interface Rectangle extends Dimensions { + x: number; + y: number; +} + +export interface SegPreviewRegion { + id: string; + index: number; + label: string; + confidence: number; + area: number; + color: string; + crop: Rectangle; + atlas: Rectangle; +} + +export interface SegPreviewDocument { + version: 1; + source: Dimensions; + preview: Dimensions & { image: ComfyImageResult }; + atlas: Dimensions & { image: ComfyImageResult }; + regions: SegPreviewRegion[]; +} + +export interface SegPreviewExecutionOutput extends ComfyNodeExecutionOutput { + [SEG_PREVIEW_OUTPUT_KEY]?: unknown; +} + +/** Return the latest valid preview document from one execution payload. */ +export function parseSegPreviewDocument( + output: unknown +): SegPreviewDocument | undefined { + if (!isRecord(output)) return undefined; + const candidates = output[SEG_PREVIEW_OUTPUT_KEY]; + if (!Array.isArray(candidates) || candidates.length === 0) return undefined; + const candidate: unknown = candidates[candidates.length - 1]; + if (!isRecord(candidate) || candidate.version !== 1) { + throw new Error("Simple Preview SEGS received an unsupported preview payload."); + } + const source = parseDimensions(candidate.source, "source"); + const preview = parseAssetDimensions(candidate.preview, "preview"); + const atlas = parseAssetDimensions(candidate.atlas, "atlas"); + if (!Array.isArray(candidate.regions)) { + throw new Error("Simple Preview SEGS regions must be a list."); + } + const regions = candidate.regions.map(parseRegion); + return { version: 1, source, preview, atlas, regions }; +} + +/** Build a Comfy view URL without assuming it is hosted at the origin root. */ +export function previewAssetUrl( + reference: ComfyImageResult, + apiURL: (path: string) => string = (path) => path +): string { + const query = new URLSearchParams({ + filename: reference.filename, + subfolder: reference.subfolder, + type: reference.type + }); + return apiURL(`/view?${query.toString()}`); +} + +function parseRegion(value: unknown, index: number): SegPreviewRegion { + if (!isRecord(value)) { + throw new Error( + `Simple Preview SEGS region ${String(index + 1)} must be an object.` + ); + } + const id = stringValue(value.id, "region id"); + const label = stringValue(value.label, "region label"); + const color = stringValue(value.color, "region color"); + if (!/^#[0-9a-f]{6}$/i.test(color)) { + throw new Error(`Simple Preview SEGS region '${id}' has an invalid color.`); + } + return { + id, + index: integerValue(value.index, "region index", 0), + label, + confidence: numberValue(value.confidence, "region confidence", 0), + area: integerValue(value.area, "region area", 0), + color, + crop: parseRectangle(value.crop, `region '${id}' crop`), + atlas: parseRectangle(value.atlas, `region '${id}' atlas`) + }; +} + +function parseAssetDimensions( + value: unknown, + name: string +): Dimensions & { image: ComfyImageResult } { + if (!isRecord(value)) { + throw new Error(`Simple Preview SEGS ${name} must be an object.`); + } + return { + ...parseDimensions(value, name), + image: parseImageReference(value.image, name) + }; +} + +function parseDimensions(value: unknown, name: string): Dimensions { + if (!isRecord(value)) { + throw new Error(`Simple Preview SEGS ${name} dimensions must be an object.`); + } + return { + width: integerValue(value.width, `${name} width`, 1), + height: integerValue(value.height, `${name} height`, 1) + }; +} + +function parseRectangle(value: unknown, name: string): Rectangle { + if (!isRecord(value)) { + throw new Error(`Simple Preview SEGS ${name} must be an object.`); + } + return { + x: integerValue(value.x, `${name} x`, 0), + y: integerValue(value.y, `${name} y`, 0), + width: integerValue(value.width, `${name} width`, 1), + height: integerValue(value.height, `${name} height`, 1) + }; +} + +function parseImageReference(value: unknown, name: string): ComfyImageResult { + if (!isRecord(value)) { + throw new Error(`Simple Preview SEGS ${name} image reference is invalid.`); + } + const type = stringValue(value.type, `${name} image type`); + if (type !== "input" && type !== "output" && type !== "temp") { + throw new Error(`Simple Preview SEGS ${name} image type is invalid.`); + } + return { + filename: stringValue(value.filename, `${name} image filename`), + subfolder: stringValue(value.subfolder, `${name} image subfolder`), + type + }; +} + +function integerValue( + value: unknown, + name: string, + minimum: number +): number { + if (!Number.isInteger(value) || (value as number) < minimum) { + throw new Error( + `Simple Preview SEGS ${name} must be at least ${String(minimum)}.` + ); + } + return value as number; +} + +function numberValue(value: unknown, name: string, minimum: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) { + throw new Error( + `Simple Preview SEGS ${name} must be at least ${String(minimum)}.` + ); + } + return value; +} + +function stringValue(value: unknown, name: string): string { + if (typeof value !== "string") { + throw new Error(`Simple Preview SEGS ${name} must be text.`); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/web/src/selectionModel.ts b/web/src/selectionModel.ts new file mode 100644 index 0000000..bcffc25 --- /dev/null +++ b/web/src/selectionModel.ts @@ -0,0 +1,72 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +export interface SelectionState { + hovered: TId | null; + selected: TId | null; + candidates: readonly TId[]; + active: TId | null; +} + +export type SelectionSubscriber = (state: SelectionState) => void; + +/** Coordinate hover and pinned selection across inspector representations. */ +export class SelectionModel { + private hovered: TId | null = null; + private selected: TId | null = null; + private candidates: readonly TId[] = []; + private readonly subscribers = new Set>(); + + /** Subscribe to selection changes and receive the current state immediately. */ + subscribe(subscriber: SelectionSubscriber): () => void { + this.subscribers.add(subscriber); + subscriber(this.state()); + return () => this.subscribers.delete(subscriber); + } + + /** Replace the hover target and all regions currently beneath the pointer. */ + hover(candidates: readonly TId[]): void { + this.candidates = [...candidates]; + this.hovered = candidates[0] ?? null; + this.publish(); + } + + /** Clear transient pointer state without changing the pinned region. */ + clearHover(): void { + this.candidates = []; + this.hovered = null; + this.publish(); + } + + /** Pin one region, or clear the pinned selection with null. */ + select(id: TId | null): void { + this.selected = id; + this.publish(); + } + + /** Cycle through overlapping pointer candidates and pin the result. */ + selectNextCandidate(): TId | null { + if (this.candidates.length === 0) return this.selected; + const currentIndex = + this.selected === null ? -1 : this.candidates.indexOf(this.selected); + const next = this.candidates[(currentIndex + 1) % this.candidates.length] ?? null; + this.select(next); + return next; + } + + /** Return immutable selection state for rendering or tests. */ + state(): SelectionState { + return { + hovered: this.hovered, + selected: this.selected, + candidates: [...this.candidates], + active: this.hovered ?? this.selected + }; + } + + private publish(): void { + const state = this.state(); + for (const subscriber of this.subscribers) subscriber(state); + } +} diff --git a/web/src/types.ts b/web/src/types.ts index 628c659..7650889 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -35,6 +35,10 @@ export interface ComfyApp { registerExtension(extension: ComfyExtension): void; } +export interface ComfyApi extends ComfyExecutionEvents { + apiURL?(path: string): string; +} + /** Native Comfy event target used to publish execution-shaped node output. */ export type ComfyExecutionEvents = Pick; @@ -53,4 +57,7 @@ export interface ComfyExtension { name: string; setup?(app: ComfyApp): void | Promise; nodeCreated?(node: unknown): void | Promise; + onNodeOutputsUpdated?( + outputs: Record + ): void | Promise; } diff --git a/web/tests/imageViewport.test.ts b/web/tests/imageViewport.test.ts new file mode 100644 index 0000000..eed3b30 --- /dev/null +++ b/web/tests/imageViewport.test.ts @@ -0,0 +1,22 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it } from "vitest"; + +import { ImageViewport } from "../src/imageViewport"; + +describe("ImageViewport", () => { + it("maps displayed pointer and crop geometry to the source image", () => { + const viewport = new ImageViewport( + { width: 2000, height: 1000 }, + { width: 1000, height: 500 } + ); + const bounds = new DOMRect(100, 50, 500, 250); + + expect(viewport.sourcePoint(350, 175, bounds)).toEqual({ x: 1000, y: 500 }); + expect( + viewport.displayRectangle({ x: 200, y: 100, width: 600, height: 300 }) + ).toEqual({ x: 100, y: 50, width: 300, height: 150 }); + }); +}); diff --git a/web/tests/interactiveInspector.test.ts b/web/tests/interactiveInspector.test.ts new file mode 100644 index 0000000..5521d3c --- /dev/null +++ b/web/tests/interactiveInspector.test.ts @@ -0,0 +1,117 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from "vitest"; + +import { + InteractiveInspectorController, + type AsyncInspectorView, + type PreparedInspectorState +} from "../src/interactiveInspector"; + +describe("InteractiveInspectorController", () => { + it("discards stale prepared documents and commits only the latest", async () => { + const first = deferred(); + const second = deferred(); + const view = fakeView([first.promise, second.promise]); + const controller = new InteractiveInspectorController(view, (output) => + typeof output === "string" ? output : undefined + ); + const stale = preparedState(); + const latest = preparedState(); + + controller.update("first"); + controller.update("second"); + second.resolve(latest); + await vi.waitFor(() => { + expect(latest.commitSpy).toHaveBeenCalledOnce(); + }); + first.resolve(stale); + await vi.waitFor(() => { + expect(stale.disposeSpy).toHaveBeenCalledOnce(); + }); + + expect(stale.commitSpy).not.toHaveBeenCalled(); + }); + + it("disposes committed state and blocks pending publication", async () => { + const pending = deferred(); + const view = fakeView([pending.promise]); + const controller = new InteractiveInspectorController(view, () => "document"); + const prepared = preparedState(); + + controller.update({}); + controller.dispose(); + pending.resolve(prepared); + await vi.waitFor(() => { + expect(prepared.disposeSpy).toHaveBeenCalledOnce(); + }); + + expect(prepared.commitSpy).not.toHaveBeenCalled(); + expect(view.disposeSpy).toHaveBeenCalledOnce(); + }); +}); + +interface TestInspectorView extends AsyncInspectorView { + disposeSpy: ReturnType void>>; +} + +function fakeView( + preparations: Array> +): TestInspectorView { + let index = 0; + const disposeSpy = vi.fn<() => void>(); + return { + element: document.createElement("div"), + setLoading: vi.fn(), + prepare: vi.fn(() => { + const preparation = preparations[index]; + index += 1; + if (!preparation) throw new Error("Missing test preparation."); + return preparation; + }), + showError: vi.fn(), + dispose: () => { + disposeSpy(); + }, + disposeSpy + }; +} + +interface TestPreparedState extends PreparedInspectorState { + commitSpy: ReturnType void>>; + disposeSpy: ReturnType void>>; +} + +function preparedState(): TestPreparedState { + const commitSpy = vi.fn<() => void>(); + const disposeSpy = vi.fn<() => void>(); + return { + commit: () => { + commitSpy(); + }, + dispose: () => { + disposeSpy(); + }, + commitSpy, + disposeSpy + }; +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolver: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolver = resolve; + }); + return { + promise, + resolve(value: T): void { + if (!resolver) throw new Error("Deferred resolver was not initialized."); + resolver(value); + } + }; +} diff --git a/web/tests/maskAtlas.test.ts b/web/tests/maskAtlas.test.ts new file mode 100644 index 0000000..ebd77bb --- /dev/null +++ b/web/tests/maskAtlas.test.ts @@ -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 + +import { describe, expect, it } from "vitest"; + +import { MaskAtlas } from "../src/maskAtlas"; +import type { SegPreviewDocument } from "../src/segPreviewTypes"; + +describe("MaskAtlas", () => { + it("returns nested hits from smallest to largest and respects mask holes", () => { + const document = previewDocument(); + const pixels = new Uint8ClampedArray(4 * 2 * 4); + setMaskPixel(pixels, 4, 0, 0, 255); + setMaskPixel(pixels, 4, 1, 0, 255); + setMaskPixel(pixels, 4, 2, 0, 255); + setMaskPixel(pixels, 4, 3, 0, 255); + setMaskPixel(pixels, 4, 0, 1, 0); + setMaskPixel(pixels, 4, 1, 1, 255); + setMaskPixel(pixels, 4, 2, 1, 255); + setMaskPixel(pixels, 4, 3, 1, 255); + const atlas = new MaskAtlas(document, pixels); + + expect(atlas.hitsAt(25, 25).map((region) => region.id)).toEqual([ + "small", + "large" + ]); + expect(atlas.hitsAt(75, 25).map((region) => region.id)).toEqual(["large"]); + expect(atlas.hitsAt(25, 75)).toEqual([]); + expect(atlas.hitsAt(150, 50)).toEqual([]); + }); +}); + +function previewDocument(): SegPreviewDocument { + const image = { filename: "asset.png", subfolder: "", type: "temp" as const }; + return { + version: 1, + source: { width: 200, height: 100 }, + preview: { width: 200, height: 100, image }, + atlas: { width: 4, height: 2, image }, + regions: [ + { + id: "large", + index: 0, + label: "subject", + confidence: 1, + area: 10000, + color: "#ff0000", + crop: { x: 0, y: 0, width: 100, height: 100 }, + atlas: { x: 0, y: 0, width: 2, height: 2 } + }, + { + id: "small", + index: 1, + label: "detail", + confidence: 1, + area: 2500, + color: "#00ff00", + crop: { x: 0, y: 0, width: 50, height: 50 }, + atlas: { x: 2, y: 0, width: 2, height: 2 } + } + ] + }; +} + +function setMaskPixel( + pixels: Uint8ClampedArray, + width: number, + x: number, + y: number, + value: number +): void { + pixels[(y * width + x) * 4] = value; +} diff --git a/web/tests/segPreviewInspector.test.ts b/web/tests/segPreviewInspector.test.ts new file mode 100644 index 0000000..8f9ed7b --- /dev/null +++ b/web/tests/segPreviewInspector.test.ts @@ -0,0 +1,99 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SegPreviewInspector } from "../src/segPreviewInspector"; +import type { SegPreviewDocument } from "../src/segPreviewTypes"; +import { installCanvasMock } from "./testUtils"; + +describe("SegPreviewInspector", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("shares pinned selection between overlay and grid views", async () => { + installCanvasMock(); + const loadImage = vi.fn(() => Promise.resolve(document.createElement("img"))); + const inspector = new SegPreviewInspector((path) => path, loadImage); + const prepared = await inspector.prepare(previewDocument()); + prepared.commit(); + const highlight = requiredElement( + inspector.element, + ".ss-segs-preview__highlight" + ); + if (!(highlight instanceof HTMLCanvasElement)) { + throw new Error("Missing highlight canvas."); + } + highlight.getBoundingClientRect = () => new DOMRect(0, 0, 100, 100); + + highlight.dispatchEvent( + new MouseEvent("pointermove", { clientX: 50, clientY: 50, bubbles: true }) + ); + expect(inspector.element.textContent).toContain("subject"); + highlight.click(); + buttonNamed(inspector.element, "Grid").click(); + + const card = requiredElement( + inspector.element, + ".ss-segs-preview__card" + ); + if (!(card instanceof HTMLButtonElement)) { + throw new Error("Missing region card."); + } + expect(card.getAttribute("aria-selected")).toBe("true"); + expect(card.dataset.highlighted).toBe("true"); + }); + + it("switches to an actionable error without throwing from the node lifecycle", () => { + installCanvasMock(); + const inspector = new SegPreviewInspector(); + + inspector.showError("Atlas could not be decoded."); + + expect(inspector.element.textContent).toContain("Atlas could not be decoded."); + expect( + inspector.element.querySelector(".ss-segs-preview__message")?.getAttribute( + "data-error" + ) + ).toBe("true"); + }); +}); + +function previewDocument(): SegPreviewDocument { + const image = { filename: "asset.png", subfolder: "", type: "temp" as const }; + return { + version: 1, + source: { width: 100, height: 100 }, + preview: { width: 100, height: 100, image }, + atlas: { width: 2, height: 2, image }, + regions: [ + { + id: "seg-0001", + index: 0, + label: "subject", + confidence: 0.95, + area: 10000, + color: "#f24236", + crop: { x: 0, y: 0, width: 100, height: 100 }, + atlas: { x: 0, y: 0, width: 2, height: 2 } + } + ] + }; +} + +function buttonNamed(root: ParentNode, name: string): HTMLButtonElement { + const button = Array.from(root.querySelectorAll("button")).find( + (candidate) => candidate.textContent === name + ); + if (!button) throw new Error(`Missing '${name}' button.`); + return button; +} + +function requiredElement(root: ParentNode, selector: string): Element { + const element = root.querySelector(selector); + if (!element) throw new Error(`Missing '${selector}' element.`); + return element; +} diff --git a/web/tests/segPreviewNode.test.ts b/web/tests/segPreviewNode.test.ts new file mode 100644 index 0000000..8e4d96a --- /dev/null +++ b/web/tests/segPreviewNode.test.ts @@ -0,0 +1,141 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { registerSimplePreviewSEGS } from "../src/segPreviewNode"; +import type { + ComfyApi, + ComfyNodeExecutionOutput, + ComfyExtension +} from "../src/types"; +import { createFakeComfyApp, installCanvasMock } from "./testUtils"; + +describe("Simple Preview SEGS node integration", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each(["Nodes 1.0", "Nodes 2.0"])( + "uses one native DOM widget under %s", + async (renderer) => { + installCanvasMock(); + const app = createFakeComfyApp(); + const api = new EventTarget() as ComfyApi; + api.apiURL = (path) => `/base${path}`; + const loadImage = vi.fn(() => Promise.resolve(document.createElement("img"))); + registerSimplePreviewSEGS(app, api, loadImage); + const extension = requiredExtension(app.extensions); + const node = fakeNode(renderer === "Nodes 2.0" ? 22 : 11); + + await extension.nodeCreated?.(node); + + expect(node.addDOMWidget).toHaveBeenCalledOnce(); + const widgetOptions = node.addDOMWidget.mock.calls[0]?.[3]; + expect(widgetOptions).toEqual({ serialize: false, canvasOnly: false }); + expect(node.setSize).toHaveBeenCalledWith([420, 520]); + + if (renderer === "Nodes 1.0") { + node.onExecuted?.(executionOutput()); + } else { + await extension.onNodeOutputsUpdated?.({ + [String(node.id)]: executionOutput() + }); + } + const root = node.previewRoot; + await vi.waitFor(() => { + expect(root.querySelector(".ss-segs-preview__canvas-stack")).not.toBeNull(); + }); + + node.onRemoved?.(); + expect(root.querySelector(".ss-segs-preview__canvas-stack")).toBeNull(); + } + ); +}); + +interface FakeDomWidget { + serialize?: boolean; + computeSize?: (width?: number) => [number, number]; + options: { serialize?: boolean; canvasOnly?: boolean }; +} + +interface FakePreviewNode { + constructor: { comfyClass: string }; + id: number; + size: [number, number]; + previewRoot: HTMLElement; + computeSize: ReturnType [number, number]>>; + setSize: ReturnType void>>; + addDOMWidget: ReturnType< + typeof vi.fn< + ( + name: string, + type: string, + element: HTMLElement, + options?: { serialize?: boolean; canvasOnly?: boolean } + ) => FakeDomWidget + > + >; + onExecuted: ((output: unknown) => void) | undefined; + onGraphConfigured: ((...args: unknown[]) => unknown) | undefined; + onRemoved: ((...args: unknown[]) => unknown) | undefined; +} + +function fakeNode(id: number): FakePreviewNode { + const previewRoot: HTMLElement = document.createElement("div"); + const node = { + constructor: { comfyClass: "SimpleSyrup.SimplePreviewSEGS" }, + id, + size: [300, 200] as [number, number], + previewRoot, + computeSize: vi.fn((): [number, number] => [300, 240]), + setSize: vi.fn<(size: [number, number]) => void>(), + addDOMWidget: vi.fn( + ( + _name: string, + _type: string, + element: HTMLElement, + options?: { serialize?: boolean; canvasOnly?: boolean } + ): FakeDomWidget => { + void options; + previewRoot.replaceWith(element); + node.previewRoot = element; + return { options: {} }; + } + ), + onExecuted: undefined as ((output: unknown) => void) | undefined, + onGraphConfigured: undefined as ((...args: unknown[]) => unknown) | undefined, + onRemoved: undefined as ((...args: unknown[]) => unknown) | undefined + }; + return node; +} + +function executionOutput(): ComfyNodeExecutionOutput & { + simple_syrup_segs_preview: unknown[]; +} { + const image = { filename: "asset.png", subfolder: "", type: "temp" as const }; + return { + images: [], + simple_syrup_segs_preview: [ + { + version: 1, + source: { width: 4, height: 4 }, + preview: { width: 4, height: 4, image }, + atlas: { width: 1, height: 1, image }, + regions: [] + } + ] + }; +} + +function requiredExtension( + extensions: ReturnType["extensions"] +): ComfyExtension { + const extension = extensions.find( + (candidate) => candidate.name === "SimpleSyrup.SimplePreviewSEGS" + ); + if (!extension) throw new Error("Simple Preview SEGS extension was not registered."); + return extension; +} diff --git a/web/tests/segPreviewTypes.test.ts b/web/tests/segPreviewTypes.test.ts new file mode 100644 index 0000000..fa859d1 --- /dev/null +++ b/web/tests/segPreviewTypes.test.ts @@ -0,0 +1,59 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it } from "vitest"; + +import { + parseSegPreviewDocument, + previewAssetUrl +} from "../src/segPreviewTypes"; + +describe("SEG preview transport", () => { + it("validates execution payloads and uses the latest mapped document", () => { + const document = validDocument(); + + expect( + parseSegPreviewDocument({ simple_syrup_segs_preview: [document] }) + ).toEqual(document); + expect(parseSegPreviewDocument({ images: [] })).toBeUndefined(); + expect(() => + parseSegPreviewDocument({ + simple_syrup_segs_preview: [{ ...document, version: 2 }] + }) + ).toThrow("unsupported preview payload"); + }); + + it("builds an encoded view URL through Comfy's base-path resolver", () => { + const url = previewAssetUrl( + { filename: "one two.png", subfolder: "a/b", type: "temp" }, + (path) => `/comfy${path}` + ); + + expect(url).toBe( + "/comfy/view?filename=one+two.png&subfolder=a%2Fb&type=temp" + ); + }); +}); + +function validDocument(): object { + const image = { filename: "asset.png", subfolder: "", type: "temp" }; + return { + version: 1, + source: { width: 10, height: 8 }, + preview: { width: 10, height: 8, image }, + atlas: { width: 4, height: 4, image }, + regions: [ + { + id: "seg-0001", + index: 0, + label: "subject", + confidence: 0.9, + area: 8, + color: "#f24236", + crop: { x: 1, y: 1, width: 4, height: 2 }, + atlas: { x: 0, y: 0, width: 4, height: 2 } + } + ] + }; +} diff --git a/web/tests/selectionModel.test.ts b/web/tests/selectionModel.test.ts new file mode 100644 index 0000000..d03b292 --- /dev/null +++ b/web/tests/selectionModel.test.ts @@ -0,0 +1,36 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from "vitest"; + +import { SelectionModel } from "../src/selectionModel"; + +describe("SelectionModel", () => { + it("shares hover and pinned selection across inspector views", () => { + const model = new SelectionModel(); + const subscriber = vi.fn(); + model.subscribe(subscriber); + + model.hover(["small", "large"]); + model.selectNextCandidate(); + model.clearHover(); + + expect(model.state()).toEqual({ + hovered: null, + selected: "small", + candidates: [], + active: "small" + }); + expect(subscriber).toHaveBeenCalledTimes(4); + }); + + it("cycles through overlapping candidates from the pinned region", () => { + const model = new SelectionModel(); + model.hover(["small", "large"]); + + expect(model.selectNextCandidate()).toBe("small"); + expect(model.selectNextCandidate()).toBe("large"); + expect(model.selectNextCandidate()).toBe("small"); + }); +}); diff --git a/web/tests/testUtils.ts b/web/tests/testUtils.ts index 528356a..d4c7e76 100644 --- a/web/tests/testUtils.ts +++ b/web/tests/testUtils.ts @@ -9,6 +9,7 @@ import type { ComfySettingDefinition, SettingValue } from "../src/types"; +import { vi } from "vitest"; export interface FakeComfySettingsApi { definitions: Array>; @@ -64,3 +65,43 @@ export function createJsonResponse( ...init }); } + +/** Install a deterministic 2D canvas surface for DOM inspector tests. */ +export function installCanvasMock(maskValue = 255): void { + const context = { + globalAlpha: 1, + shadowColor: "", + shadowBlur: 0, + clearRect: vi.fn(), + drawImage: vi.fn(), + fillRect: vi.fn(), + getImageData: vi.fn((x: number, y: number, width: number, height: number) => { + void x; + void y; + const data = new Uint8ClampedArray(width * height * 4); + for (let offset = 0; offset < data.length; offset += 4) { + data[offset] = maskValue; + data[offset + 1] = maskValue; + data[offset + 2] = maskValue; + data[offset + 3] = 255; + } + return { data, width, height, colorSpace: "srgb" }; + }), + putImageData: vi.fn(), + restore: vi.fn(), + save: vi.fn() + } as unknown as CanvasRenderingContext2D; + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context); + vi.stubGlobal( + "ImageData", + class { + readonly colorSpace = "srgb"; + + constructor( + readonly data: Uint8ClampedArray, + readonly width: number, + readonly height: number + ) {} + } + ); +} From 667073a673cc491321cc1174227a70aa09d9b67d Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Mon, 3 Aug 2026 21:55:16 -0400 Subject: [PATCH 29/40] feat(sampling): expose evaluated context SEGS --- simple_syrup/domain/context_segs.py | 170 ++++++++++++++++++ .../nodes/ksampler_contextual_diffusion.py | 14 +- simple_syrup/nodes/tooltips.py | 4 + simple_syrup/runtime/latent_geometry.py | 35 ++++ simple_syrup/runtime/tiled_sampling.py | 63 +++++++ .../contextual_diffusion_sampling_service.py | 56 +++++- tests/test_context_segs.py | 86 +++++++++ tests/test_contextual_diffusion_sampling.py | 53 ++++++ ...t_contextual_diffusion_sampling_service.py | 7 +- ...test_ksampler_contextual_diffusion_node.py | 30 +++- tests/test_latent_geometry.py | 64 +++++++ tests/test_tiled_sampling_runtime.py | 72 ++++++++ 12 files changed, 637 insertions(+), 17 deletions(-) create mode 100644 simple_syrup/domain/context_segs.py create mode 100644 simple_syrup/runtime/latent_geometry.py create mode 100644 tests/test_context_segs.py create mode 100644 tests/test_latent_geometry.py diff --git a/simple_syrup/domain/context_segs.py b/simple_syrup/domain/context_segs.py new file mode 100644 index 0000000..1da5bf3 --- /dev/null +++ b/simple_syrup/domain/context_segs.py @@ -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.") diff --git a/simple_syrup/nodes/ksampler_contextual_diffusion.py b/simple_syrup/nodes/ksampler_contextual_diffusion.py index d187af2..e624354 100644 --- a/simple_syrup/nodes/ksampler_contextual_diffusion.py +++ b/simple_syrup/nodes/ksampler_contextual_diffusion.py @@ -22,8 +22,12 @@ MAX_LATENT_CONTEXT_SIZE = 512 class KSamplerContextualDiffusion: """Edit large latents through coordinated global and detailed contexts.""" - RETURN_TYPES = ("LATENT",) - OUTPUT_TOOLTIPS = (tooltips.DENOISED_LATENT_OUTPUT,) + RETURN_TYPES = ("LATENT", "SEGS") + RETURN_NAMES = ("latent", "contexts_segs") + OUTPUT_TOOLTIPS = ( + tooltips.DENOISED_LATENT_OUTPUT, + tooltips.CONTEXTUAL_DIFFUSION_CONTEXTS_OUTPUT, + ) FUNCTION = "sample" CATEGORY = "SimpleSyrup/Sampling" DESCRIPTION = ( @@ -202,10 +206,10 @@ class KSamplerContextualDiffusion: global_steps: int = 1, global_decay: float = 0.5, segs: object | None = None, - ) -> tuple[Latent]: + ) -> tuple[Latent, object]: """Delegate contextual diffusion sampling to its application service.""" - output = self.service_class().sample( + result = self.service_class().sample( model=model, seed=seed, steps=steps, @@ -225,4 +229,4 @@ class KSamplerContextualDiffusion: global_decay=global_decay, segs=segs, ) - return (output,) + return result.latent, result.contexts diff --git a/simple_syrup/nodes/tooltips.py b/simple_syrup/nodes/tooltips.py index b4ac98b..3fd85b1 100644 --- a/simple_syrup/nodes/tooltips.py +++ b/simple_syrup/nodes/tooltips.py @@ -137,6 +137,10 @@ DENOISE_STRENGTH = ( "larger changes." ) DENOISED_LATENT_OUTPUT = "Denoised latent for VAE decode or more latent processing." +CONTEXTUAL_DIFFUSION_CONTEXTS_OUTPUT = ( + "Rectangular non-global contexts actually evaluated during sampling. Their " + "SEGS masks are created only when this output is connected." +) TILED_DIFFUSION_MODE = ( "Tile overlap blend. MultiDiffusion averages predictions; Mixture of Diffusers " "gives tile centers more influence." diff --git a/simple_syrup/runtime/latent_geometry.py b/simple_syrup/runtime/latent_geometry.py new file mode 100644 index 0000000..7e5f48f --- /dev/null +++ b/simple_syrup/runtime/latent_geometry.py @@ -0,0 +1,35 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Resolve decoded image geometry from ComfyUI latent runtime metadata.""" + +from __future__ import annotations + +from typing import Any + + +def decoded_image_dimensions( + *, + model: Any, + latent_image: dict[str, Any], + latent_height: int, + latent_width: int, +) -> tuple[int, int]: + """Return decoded height and width using ComfyUI's spatial latent ratio.""" + + ratio = latent_image.get("downscale_ratio_spacial") + if ratio is None: + get_model_object = getattr(model, "get_model_object", None) + if not callable(get_model_object): + raise ValueError( + "Context SEGS requires SEGS input dimensions or a model exposing " + "its latent format." + ) + latent_format = get_model_object("latent_format") + ratio = getattr(latent_format, "spacial_downscale_ratio", None) + if isinstance(ratio, bool) or not isinstance(ratio, (int, float)) or ratio <= 0: + raise ValueError( + "Context SEGS requires a positive latent spatial downscale ratio." + ) + return round(latent_height * ratio), round(latent_width * ratio) diff --git a/simple_syrup/runtime/tiled_sampling.py b/simple_syrup/runtime/tiled_sampling.py index b6d6a21..0d0a707 100644 --- a/simple_syrup/runtime/tiled_sampling.py +++ b/simple_syrup/runtime/tiled_sampling.py @@ -31,6 +31,7 @@ ModelFunctionWrapper: TypeAlias = Callable[[ApplyModel, dict[str, Any]], torch.T TileEvaluator: TypeAlias = Callable[[dict[str, Any]], torch.Tensor] UNSUPPORTED_CONDITIONING_KEYS = frozenset({"area", "control", "gligen"}) +CONTEXT_INVARIANT_CONDITIONING_KEYS = frozenset({"ref_latents"}) @dataclass(frozen=True) @@ -463,6 +464,14 @@ def spatial_context_conditioning( tiled_timestep=context_timestep, ) continue + if key in CONTEXT_INVARIANT_CONDITIONING_KEYS: + transformed[key] = repeat_context_invariant_value( + value, + context_count=len(contexts), + input_batch_size=input_batch_size, + conditioning_key=key, + ) + continue transformed[key] = spatial_context_value( value, contexts=contexts, @@ -587,6 +596,14 @@ def tile_conditioning( tiled_timestep=tiled_timestep, ) continue + if key in CONTEXT_INVARIANT_CONDITIONING_KEYS: + tiled[key] = repeat_context_invariant_value( + value, + context_count=len(tiles), + input_batch_size=input_batch_size, + conditioning_key=key, + ) + continue tiled[key] = tile_value( value, tiles=tiles, @@ -597,6 +614,52 @@ def tile_conditioning( return tiled +def repeat_context_invariant_value( + value: Any, + *, + context_count: int, + input_batch_size: int, + conditioning_key: str, +) -> Any: + """Repeat non-spatial conditioning without cropping its tensor contents.""" + + if isinstance(value, torch.Tensor): + if value.ndim < 1: + raise ValueError( + f"{conditioning_key} tensors must include a batch dimension." + ) + if value.shape[0] == input_batch_size: + return torch.cat([value] * context_count, dim=0) + if value.shape[0] == 1: + repeats = [input_batch_size * context_count] + [1] * (value.ndim - 1) + return value.repeat(repeats) + raise ValueError( + f"{conditioning_key} tensor batch size must be 1 or match the model " + f"input batch size {input_batch_size}; received {value.shape[0]}." + ) + if isinstance(value, list): + return [ + repeat_context_invariant_value( + item, + context_count=context_count, + input_batch_size=input_batch_size, + conditioning_key=conditioning_key, + ) + for item in value + ] + if isinstance(value, tuple): + return tuple( + repeat_context_invariant_value( + item, + context_count=context_count, + input_batch_size=input_batch_size, + conditioning_key=conditioning_key, + ) + for item in value + ) + return value + + def tile_transformer_options( options: dict[str, Any], *, diff --git a/simple_syrup/services/contextual_diffusion_sampling_service.py b/simple_syrup/services/contextual_diffusion_sampling_service.py index 7f14550..622f643 100644 --- a/simple_syrup/services/contextual_diffusion_sampling_service.py +++ b/simple_syrup/services/contextual_diffusion_sampling_service.py @@ -6,11 +6,17 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any, TypeAlias import torch from ..domain.conditioning_batch import ConditioningBatch, select_conditioning +from ..domain.context_segs import ( + ContextSegs, + context_segs_from_tile_plan, + merge_context_segs, +) from ..domain.contextual_diffusion import ( ContextualDiffusionControls, build_contextual_diffusion_plan, @@ -18,6 +24,7 @@ from ..domain.contextual_diffusion import ( from ..domain.segs import NativeSegs, coerce_segs_group from ..domain.tiled_diffusion import validate_tiled_diffusion_mode from ..runtime.contextual_diffusion_sampling import sample_contextual_diffusion +from ..runtime.latent_geometry import decoded_image_dimensions from .sampling_batch import ( combine_latent_outputs, latent_batch_size, @@ -27,6 +34,14 @@ from .sampling_batch import ( Latent: TypeAlias = dict[str, Any] +@dataclass(frozen=True) +class ContextualDiffusionSamplingResult: + """Return the sampled latent and lazy non-global context SEGS.""" + + latent: Latent + contexts: ContextSegs + + class ContextualDiffusionSamplingService: """Plan and execute composition-preserving contextual diffusion.""" @@ -51,7 +66,7 @@ class ContextualDiffusionSamplingService: global_steps: int, global_decay: float, segs: object | None = None, - ) -> Latent: + ) -> ContextualDiffusionSamplingResult: """Sample a latent with global and bounded detail contexts.""" validate_tiled_diffusion_mode(diffusion_mode) @@ -71,6 +86,16 @@ class ContextualDiffusionSamplingService: "Contextual Diffusion requires one SEGS payload or one per latent " f"batch item; received {len(segs_group)} for batch size {batch_size}." ) + image_height, image_width = ( + segs_group[0][0] + if segs_group + else decoded_image_dimensions( + model=model, + latent_image=latent_image, + latent_height=int(latent_image["samples"].shape[-2]), + latent_width=int(latent_image["samples"].shape[-1]), + ) + ) split_batch = ( bool(segs_group) or isinstance(positive, ConditioningBatch) @@ -91,11 +116,14 @@ class ContextualDiffusionSamplingService: diffusion_mode=diffusion_mode, controls=controls, segs=None, + image_height=image_height, + image_width=image_width, ) outputs: list[torch.Tensor] = [] + contexts: list[ContextSegs] = [] for index in range(batch_size): - item_output = self._sample_item( + item_result = self._sample_item( model=model, seed=seed, steps=steps, @@ -121,14 +149,20 @@ class ContextualDiffusionSamplingService: if segs_group else None ), + image_height=image_height, + image_width=image_width, ) - samples = item_output.get("samples") + samples = item_result.latent.get("samples") if not isinstance(samples, torch.Tensor): raise TypeError( "Contextual Diffusion output samples must be a torch.Tensor." ) outputs.append(samples) - return combine_latent_outputs(latent_image, outputs) + contexts.append(item_result.contexts) + return ContextualDiffusionSamplingResult( + latent=combine_latent_outputs(latent_image, outputs), + contexts=merge_context_segs(contexts), + ) def _sample_item( self, @@ -146,7 +180,9 @@ class ContextualDiffusionSamplingService: diffusion_mode: str, controls: ContextualDiffusionControls, segs: NativeSegs | None, - ) -> Latent: + image_height: int, + image_width: int, + ) -> ContextualDiffusionSamplingResult: """Build one canvas plan and execute it through the runtime adapter.""" samples = latent_image.get("samples") @@ -160,7 +196,7 @@ class ContextualDiffusionSamplingService: controls=controls, segs=segs, ) - return sample_contextual_diffusion( + latent = sample_contextual_diffusion( model=model, seed=seed, steps=steps, @@ -175,3 +211,11 @@ class ContextualDiffusionSamplingService: controls=controls, plan=plan, ) + return ContextualDiffusionSamplingResult( + latent=latent, + contexts=context_segs_from_tile_plan( + plan.tile_plan, + image_height=image_height, + image_width=image_width, + ), + ) diff --git a/tests/test_context_segs.py b/tests/test_context_segs.py new file mode 100644 index 0000000..3fdfddb --- /dev/null +++ b/tests/test_context_segs.py @@ -0,0 +1,86 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for projecting authoritative sampling contexts into SEGS.""" + +from __future__ import annotations + +import torch + +from simple_syrup.domain.context_segs import context_segs_from_tile_plan +from simple_syrup.domain.segs import BoundingBox, coerce_segs +from simple_syrup.domain.tiled_diffusion import build_tiled_diffusion_plan +from simple_syrup.services.simple_preview_segs_service import SimplePreviewSEGSService + + +def test_tile_plan_projects_exact_non_global_context_rectangles() -> None: + """Every evaluated tile becomes one full rectangular SEG in image space.""" + + plan = build_tiled_diffusion_plan( + latent_width=64, + latent_height=32, + tile_width=32, + tile_height=24, + overlap=8, + tile_batch_size=2, + ) + + header, contexts = context_segs_from_tile_plan( + plan, + image_height=256, + image_width=512, + ) + + assert header == (256, 512) + assert not contexts.is_materialized + assert len(contexts) == len(plan.tiles) + assert not contexts.is_materialized + assert [context.label for context in contexts] == [ + f"context_{index:03d}" for index in range(1, len(contexts) + 1) + ] + assert all(context.crop_region != (0, 0, 512, 256) for context in contexts) + for tile, context in zip(plan.tiles, contexts, strict=True): + assert context.crop_region == ( + tile.x * 8, + tile.y * 8, + (tile.x + tile.width) * 8, + (tile.y + tile.height) * 8, + ) + assert context.bbox == BoundingBox(*context.crop_region) + mask = context.cropped_mask + assert isinstance(mask, torch.Tensor) + assert mask.dtype == torch.uint8 + assert tuple(mask.shape) == ( + context.crop_region.height, + context.crop_region.width, + ) + assert bool(mask.all()) + assert contexts.is_materialized + + +def test_lazy_contexts_feed_simple_preview_segs_directly() -> None: + """Simple Preview SEGS materializes and displays a connected context output.""" + + plan = build_tiled_diffusion_plan( + latent_width=16, + latent_height=8, + tile_width=8, + tile_height=8, + overlap=2, + tile_batch_size=2, + ) + contexts_segs = context_segs_from_tile_plan( + plan, + image_height=64, + image_width=128, + ) + + document = SimplePreviewSEGSService().build( + image=torch.zeros((1, 64, 128, 3)), + segs=coerce_segs(contexts_segs), + ) + + assert contexts_segs[1].is_materialized + assert len(document.regions) == len(plan.tiles) + assert all(region.label.startswith("context_") for region in document.regions) diff --git a/tests/test_contextual_diffusion_sampling.py b/tests/test_contextual_diffusion_sampling.py index 92291a0..cf13be3 100644 --- a/tests/test_contextual_diffusion_sampling.py +++ b/tests/test_contextual_diffusion_sampling.py @@ -119,6 +119,59 @@ def test_global_prediction_decays_then_stops_after_configured_steps() -> None: assert torch.allclose(output, torch.ones_like(output)) +def test_every_prediction_receives_each_complete_reference_image() -> None: + """Local and global predictions preserve ordered multi-image conditioning.""" + + controls = _controls() + plan = build_contextual_diffusion_plan( + latent_width=32, + latent_height=16, + controls=controls, + segs=None, + ) + wrapper = ContextualDiffusionModelWrapper( + plan=plan, + controls=controls, + sigmas=torch.tensor([1.0, 0.0]), + existing_wrapper=None, + ) + image_1 = torch.arange(1 * 4 * 16 * 32, dtype=torch.float32).reshape((1, 4, 16, 32)) + image_2 = torch.full((1, 4, 10, 12), 2.0) + received: list[list[torch.Tensor]] = [] + + def apply_model( + x: torch.Tensor, + timestep: torch.Tensor, + **conditioning: object, + ) -> torch.Tensor: + """Capture reference batches presented to each bounded prediction.""" + + del timestep + references = conditioning["ref_latents"] + assert isinstance(references, list) + assert all(isinstance(reference, torch.Tensor) for reference in references) + received.append(references) + return torch.zeros_like(x) + + wrapper( + apply_model, + { + "input": torch.zeros((1, 1, 16, 32)), + "timestep": torch.tensor([1.0]), + "c": { + "ref_latents": [image_1, image_2], + "ref_latents_method": "index", + }, + }, + ) + + assert len(received) == 2 + assert torch.equal(received[0][0], torch.cat([image_1, image_1], dim=0)) + assert torch.equal(received[0][1], torch.cat([image_2, image_2], dim=0)) + assert torch.equal(received[1][0], image_1) + assert torch.equal(received[1][1], image_2) + + def test_native_sized_canvas_delegates_to_one_original_model_call() -> None: """A canvas already inside the model view limit behaves like normal sampling.""" diff --git a/tests/test_contextual_diffusion_sampling_service.py b/tests/test_contextual_diffusion_sampling_service.py index a7522ec..8c071cd 100644 --- a/tests/test_contextual_diffusion_sampling_service.py +++ b/tests/test_contextual_diffusion_sampling_service.py @@ -47,7 +47,12 @@ def test_service_builds_global_context_and_segs_guided_tile_plan( **_sample_kwargs(latent=latent, segs=_segs(512, 768)) ) - assert torch.equal(result["samples"], latent["samples"]) + assert torch.equal(result.latent["samples"], latent["samples"]) + assert result.contexts[0] == (512, 768) + assert len(result.contexts[1]) == len(calls[0]["plan"].tile_plan.tiles) + assert not result.contexts[1].is_materialized + assert all(segment.label.startswith("context_") for segment in result.contexts[1]) + assert result.contexts[1].is_materialized assert len(calls) == 1 assert calls[0]["diffusion_mode"] == "mixture_of_diffusers" plan = calls[0]["plan"] diff --git a/tests/test_ksampler_contextual_diffusion_node.py b/tests/test_ksampler_contextual_diffusion_node.py index 944945b..2d420bd 100644 --- a/tests/test_ksampler_contextual_diffusion_node.py +++ b/tests/test_ksampler_contextual_diffusion_node.py @@ -11,9 +11,13 @@ from typing import Any import pytest import torch +from simple_syrup.domain.segs import NativeSegs from simple_syrup.nodes.ksampler_contextual_diffusion import ( KSamplerContextualDiffusion, ) +from simple_syrup.nodes_v3.legacy_node_wrappers import ( + KSamplerContextualDiffusionV3, +) from simple_syrup.runtime import sampling_samplers, sampling_schedulers @@ -68,14 +72,24 @@ def test_input_types_expose_concise_klein_oriented_controls( def test_node_metadata_matches_separate_sampler_contract() -> None: - """Contextual Diffusion remains a distinct sampler with latent output.""" + """Contextual Diffusion exposes its latent and actual local contexts.""" - assert KSamplerContextualDiffusion.RETURN_TYPES == ("LATENT",) + assert KSamplerContextualDiffusion.RETURN_TYPES == ("LATENT", "SEGS") + assert KSamplerContextualDiffusion.RETURN_NAMES == ("latent", "contexts_segs") + assert len(KSamplerContextualDiffusion.OUTPUT_TOOLTIPS) == 2 assert KSamplerContextualDiffusion.FUNCTION == "sample" assert KSamplerContextualDiffusion.CATEGORY == "SimpleSyrup/Sampling" assert "composition" in KSamplerContextualDiffusion.DESCRIPTION +def test_v3_schema_names_both_contextual_diffusion_outputs() -> None: + """The exported v3 schema exposes the workflow-facing context socket.""" + + schema = KSamplerContextualDiffusionV3.define_schema() + + assert [output.id for output in schema.outputs] == ["latent", "contexts_segs"] + + def test_sample_delegates_every_control_to_service( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -90,7 +104,7 @@ def test_sample_delegates_every_control_to_service( latent = {"samples": torch.zeros((1, 4, 32, 48))} segs = object() - (result,) = KSamplerContextualDiffusion().sample( + result, contexts = KSamplerContextualDiffusion().sample( model="model", seed=12, steps=8, @@ -112,6 +126,7 @@ def test_sample_delegates_every_control_to_service( ) assert result is fake_service.output + assert contexts is fake_service.contexts assert fake_service.calls == [ { "model": "model", @@ -143,10 +158,15 @@ class _FakeContextualDiffusionService: """Create a stable output and empty call history.""" self.output: dict[str, Any] = {"samples": torch.ones((1, 4, 32, 48))} + self.contexts: NativeSegs = ((256, 384), ()) self.calls: list[dict[str, Any]] = [] - def sample(self, **kwargs: Any) -> dict[str, Any]: + def sample(self, **kwargs: Any) -> Any: """Record one call and return the stable latent.""" self.calls.append(kwargs) - return self.output + return type( + "Result", + (), + {"latent": self.output, "contexts": self.contexts}, + )() diff --git a/tests/test_latent_geometry.py b/tests/test_latent_geometry.py new file mode 100644 index 0000000..2fbedfd --- /dev/null +++ b/tests/test_latent_geometry.py @@ -0,0 +1,64 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for decoded image geometry derived from Comfy latent metadata.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from simple_syrup.runtime.latent_geometry import decoded_image_dimensions + + +@dataclass(frozen=True) +class _LatentFormat: + """Expose ComfyUI's intentionally misspelled spatial ratio attribute.""" + + spacial_downscale_ratio: int + + +class _Model: + """Provide one latent format through the ModelPatcher boundary.""" + + def get_model_object(self, name: str) -> object: + """Return the requested latent format fixture.""" + + assert name == "latent_format" + return _LatentFormat(8) + + +def test_decoded_dimensions_prefer_explicit_latent_ratio() -> None: + """Per-latent metadata overrides the model format for generated geometry.""" + + assert decoded_image_dimensions( + model=_Model(), + latent_image={"downscale_ratio_spacial": 16}, + latent_height=32, + latent_width=48, + ) == (512, 768) + + +def test_decoded_dimensions_fall_back_to_model_latent_format() -> None: + """Ordinary latents use the model's authoritative spatial scale.""" + + assert decoded_image_dimensions( + model=_Model(), + latent_image={}, + latent_height=32, + latent_width=48, + ) == (256, 384) + + +def test_decoded_dimensions_reject_missing_geometry_source() -> None: + """A model-neutral context output fails clearly when scale is unknowable.""" + + with pytest.raises(ValueError, match="latent format"): + decoded_image_dimensions( + model=object(), + latent_image={}, + latent_height=32, + latent_width=48, + ) diff --git a/tests/test_tiled_sampling_runtime.py b/tests/test_tiled_sampling_runtime.py index dde1ef5..a55a984 100644 --- a/tests/test_tiled_sampling_runtime.py +++ b/tests/test_tiled_sampling_runtime.py @@ -171,6 +171,78 @@ def test_tile_transformer_options_repeats_model_metadata() -> None: assert torch.equal(tiled["sample_sigmas"], torch.tensor([1.0, 0.0])) +def test_tile_conditioning_repeats_complete_reference_latents() -> None: + """Independent reference images remain complete for every tiled model call.""" + + canvas_sized_reference = torch.arange( + 1 * 4 * 4 * 8, + dtype=torch.float32, + ).reshape((1, 4, 4, 8)) + smaller_reference = torch.full((1, 4, 3, 5), 7.0) + tiles = (LatentTile(0, 0, 4, 4), LatentTile(4, 0, 4, 4)) + + transformed = tiled_sampling.tile_conditioning( + conditioning={ + "ref_latents": [canvas_sized_reference, smaller_reference], + "ref_latents_method": "index", + }, + tiles=tiles, + input_batch_size=1, + latent_height=4, + latent_width=8, + tiled_timestep=torch.tensor([1.0, 1.0]), + ) + + references = transformed["ref_latents"] + assert isinstance(references, list) + assert torch.equal( + references[0], + torch.cat([canvas_sized_reference, canvas_sized_reference], dim=0), + ) + assert torch.equal( + references[1], + torch.cat([smaller_reference, smaller_reference], dim=0), + ) + assert transformed["ref_latents_method"] == "index" + + +def test_spatial_context_conditioning_repeats_complete_reference_latents() -> None: + """Global and semantic contexts share complete independent reference images.""" + + reference = torch.arange(1 * 4 * 8 * 12, dtype=torch.float32).reshape((1, 4, 8, 12)) + contexts = ( + SpatialContext(0, 0, 12, 8, 6, 4), + SpatialContext(2, 1, 8, 6, 6, 4), + ) + + transformed = tiled_sampling.spatial_context_conditioning( + conditioning={"ref_latents": [reference]}, + contexts=contexts, + input_batch_size=1, + latent_height=8, + latent_width=12, + context_timestep=torch.tensor([1.0, 1.0]), + ) + + references = transformed["ref_latents"] + assert isinstance(references, list) + assert torch.equal(references[0], torch.cat([reference, reference], dim=0)) + + +def test_reference_latents_reject_ambiguous_batch_alignment() -> None: + """Reference batches must align explicitly with each model input batch.""" + + with pytest.raises(ValueError, match="ref_latents tensor batch size"): + tiled_sampling.tile_conditioning( + conditioning={"ref_latents": [torch.zeros((3, 4, 8, 12))]}, + tiles=(LatentTile(0, 0, 6, 8), LatentTile(6, 0, 6, 8)), + input_batch_size=2, + latent_height=8, + latent_width=12, + tiled_timestep=torch.ones((4,)), + ) + + def test_spatial_context_args_resize_latent_and_canvas_conditioning() -> None: """A global context resizes spatial tensors and repeats aligned metadata.""" From dce4327f9e0241b08bb1c67206295bc2b063b88f Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Mon, 3 Aug 2026 21:59:26 -0400 Subject: [PATCH 30/40] feat(media): add native ordered loaders and SEGS preview --- simple_syrup/domain/ordered_files.py | 55 + simple_syrup/nodes/simple_preview_segs.py | 7 +- simple_syrup/nodes_v3/__init__.py | 2 + simple_syrup/nodes_v3/load_image_list.py | 96 + simple_syrup/nodes_v3/load_mask_batch.py | 9 +- simple_syrup/runtime/image_file_loader.py | 80 + simple_syrup/runtime/seg_preview_assets.py | 23 +- .../services/load_image_list_service.py | 68 + .../services/load_mask_batch_service.py | 38 +- .../services/simple_preview_segs_service.py | 25 + tests/test_image_file_loader.py | 123 + tests/test_license_headers.py | 24 + tests/test_load_image_list_service.py | 105 + tests/test_load_image_list_v3_node.py | 107 + tests/test_load_mask_batch_v3_node.py | 21 +- tests/test_ordered_files.py | 61 + tests/test_regional_prompt_workflow.py | 4 - tests/test_registration.py | 1 + tests/test_seg_preview_assets.py | 22 +- tests/test_simple_preview_segs_node.py | 12 +- tests/test_simple_preview_segs_service.py | 33 + tools/add_license_headers.py | 2 +- web/dist/simple-syrup.js | 2147 ++++++++++++----- web/src/comfyImageReference.ts | 40 + web/src/comfyImageUrl.ts | 29 + web/src/domWidgetLayout.ts | 45 + web/src/imageListUpload.ts | 35 + web/src/main.ts | 2 + web/src/maskBatchPreview.ts | 73 - web/src/maskBatchUpload.ts | 501 +--- web/src/maskedTextureRenderer.ts | 55 + web/src/nativeNodePreview.ts | 55 + web/src/nativePreviewLifecycle.ts | 34 + web/src/nativePreviewNavigator.ts | 163 ++ web/src/orderedMediaNode.ts | 375 +++ web/src/orderedMediaPreview.ts | 68 + web/src/orderedMediaPreviewActions.ts | 283 +++ web/src/orderedMediaPreviewAffordances.ts | 225 ++ web/src/orderedMediaPreviewTransaction.ts | 116 + web/src/orderedMediaSelection.ts | 56 + web/src/segPreviewInspector.ts | 267 +- web/src/segPreviewNativeSurface.ts | 174 ++ web/src/segPreviewNode.ts | 60 +- web/src/segPreviewTypes.ts | 13 - web/src/types.ts | 11 + web/tests/imageListUpload.test.ts | 31 + web/tests/maskBatchPreview.test.ts | 157 -- web/tests/maskBatchUpload.test.ts | 668 ++--- web/tests/maskedTextureRenderer.test.ts | 61 + web/tests/nativeNodePreview.test.ts | 58 + web/tests/nativePreviewNavigator.test.ts | 199 ++ web/tests/orderedMediaNode.test.ts | 649 +++++ web/tests/orderedMediaPreview.test.ts | 127 + web/tests/orderedMediaPreviewActions.test.ts | 170 ++ .../orderedMediaPreviewAffordances.test.ts | 122 + .../orderedMediaPreviewTransaction.test.ts | 90 + web/tests/orderedMediaSelection.test.ts | 44 + web/tests/segPreviewInspector.test.ts | 64 +- web/tests/segPreviewNativeSurface.test.ts | 164 ++ web/tests/segPreviewNode.test.ts | 88 +- web/tests/segPreviewTypes.test.ts | 8 +- 61 files changed, 6320 insertions(+), 2125 deletions(-) create mode 100644 simple_syrup/domain/ordered_files.py create mode 100644 simple_syrup/nodes_v3/load_image_list.py create mode 100644 simple_syrup/runtime/image_file_loader.py create mode 100644 simple_syrup/services/load_image_list_service.py create mode 100644 tests/test_image_file_loader.py create mode 100644 tests/test_load_image_list_service.py create mode 100644 tests/test_load_image_list_v3_node.py create mode 100644 tests/test_ordered_files.py create mode 100644 web/src/comfyImageReference.ts create mode 100644 web/src/comfyImageUrl.ts create mode 100644 web/src/domWidgetLayout.ts create mode 100644 web/src/imageListUpload.ts delete mode 100644 web/src/maskBatchPreview.ts create mode 100644 web/src/maskedTextureRenderer.ts create mode 100644 web/src/nativeNodePreview.ts create mode 100644 web/src/nativePreviewLifecycle.ts create mode 100644 web/src/nativePreviewNavigator.ts create mode 100644 web/src/orderedMediaNode.ts create mode 100644 web/src/orderedMediaPreview.ts create mode 100644 web/src/orderedMediaPreviewActions.ts create mode 100644 web/src/orderedMediaPreviewAffordances.ts create mode 100644 web/src/orderedMediaPreviewTransaction.ts create mode 100644 web/src/orderedMediaSelection.ts create mode 100644 web/src/segPreviewNativeSurface.ts create mode 100644 web/tests/imageListUpload.test.ts delete mode 100644 web/tests/maskBatchPreview.test.ts create mode 100644 web/tests/maskedTextureRenderer.test.ts create mode 100644 web/tests/nativeNodePreview.test.ts create mode 100644 web/tests/nativePreviewNavigator.test.ts create mode 100644 web/tests/orderedMediaNode.test.ts create mode 100644 web/tests/orderedMediaPreview.test.ts create mode 100644 web/tests/orderedMediaPreviewActions.test.ts create mode 100644 web/tests/orderedMediaPreviewAffordances.test.ts create mode 100644 web/tests/orderedMediaPreviewTransaction.test.ts create mode 100644 web/tests/orderedMediaSelection.test.ts create mode 100644 web/tests/segPreviewNativeSurface.test.ts diff --git a/simple_syrup/domain/ordered_files.py b/simple_syrup/domain/ordered_files.py new file mode 100644 index 0000000..171389f --- /dev/null +++ b/simple_syrup/domain/ordered_files.py @@ -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() diff --git a/simple_syrup/nodes/simple_preview_segs.py b/simple_syrup/nodes/simple_preview_segs.py index 87e52ef..fff0487 100644 --- a/simple_syrup/nodes/simple_preview_segs.py +++ b/simple_syrup/nodes/simple_preview_segs.py @@ -57,8 +57,11 @@ class SimplePreviewSEGS: native_segs = coerce_segs(segs) document = self.service_class().build(image=image, segs=native_segs) - manifest = self.publisher_class().publish(document) + publication = self.publisher_class().publish(document) return { - "ui": {SEG_PREVIEW_UI_KEY: [manifest]}, + "ui": { + "images": list(publication.images), + SEG_PREVIEW_UI_KEY: [publication.manifest], + }, "result": (segs,), } diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index 79ebb24..193f16f 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -47,6 +47,7 @@ def get_nodes() -> list[type[object]]: UpscaleLatentFromImageV3, ViTMatteModelLoaderV3, ) + from .load_image_list import LoadImageListV3 from .load_mask_batch import LoadMaskBatchV3 from .mask_to_segs import MaskToSEGSV3 from .scale_factor import ScaleFactorV3 @@ -80,6 +81,7 @@ def get_nodes() -> list[type[object]]: LatentDiagnosticsV3, LayerStyleSAMModelsAdapterV3, LoadUltralyticsModelV3, + LoadImageListV3, LoadMaskBatchV3, MaskToSEGSV3, PromptEncodeStyleAndNormalizationV3, diff --git a/simple_syrup/nodes_v3/load_image_list.py b/simple_syrup/nodes_v3/load_image_list.py new file mode 100644 index 0000000..9193a36 --- /dev/null +++ b/simple_syrup/nodes_v3/load_image_list.py @@ -0,0 +1,96 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Comfy v3 node for loading an ordered list of authored images.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..services.load_image_list_service import LoadImageListService + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_api: Any = None if TYPE_CHECKING else import_module("comfy_api.latest") +_comfy_io: Any = None if TYPE_CHECKING else _comfy_api.io + + +class LoadImageListV3(_ComfyNodeBase): + """Load ordered files as a native Comfy IMAGE execution list.""" + + service_class: ClassVar[type[LoadImageListService]] = LoadImageListService + + @classmethod + def define_schema(cls) -> Any: + """Declare the persisted multi-upload and IMAGE list output.""" + + choices = list(cls.service_class().available_files()) + return _comfy_io.Schema( + node_id="SimpleSyrup.LoadImageList", + display_name="Load Image List", + category="SimpleSyrup/Loaders", + description=( + "Loads an ordered image list without resizing or batching its items." + ), + search_aliases=["load images", "image list", "reference images"], + inputs=[ + _comfy_io.MultiCombo.Input( + "image", + options=choices, + default=[], + placeholder="Select one or more images", + chip=True, + tooltip=( + "Select images in order; each file becomes one independent " + "IMAGE list item." + ), + extra_dict={ + "image_upload": True, + "image_folder": "input", + "allow_batch": True, + }, + ), + ], + outputs=[ + _comfy_io.Image.Output( + "images", + tooltip=( + "Ordered IMAGE list with one independently sized item per file." + ), + is_output_list=True, + ), + ], + ) + + @classmethod + def execute(cls, image: str | list[str]) -> Any: + """Return the selected images as a native Comfy execution list.""" + + return _comfy_io.NodeOutput(cls.service_class().load(image)) + + @classmethod + def validate_inputs(cls, image: str | list[str]) -> bool | str: + """Validate widget values before image loading.""" + + try: + cls.service_class().validate(image) + except (TypeError, ValueError) as error: + return str(error) + return True + + @classmethod + def fingerprint_inputs(cls, image: str | list[str]) -> str: + """Fingerprint ordered filenames and file contents.""" + + return cls.service_class().fingerprint(image) diff --git a/simple_syrup/nodes_v3/load_mask_batch.py b/simple_syrup/nodes_v3/load_mask_batch.py index d63acfb..b13bcc1 100644 --- a/simple_syrup/nodes_v3/load_mask_batch.py +++ b/simple_syrup/nodes_v3/load_mask_batch.py @@ -25,7 +25,6 @@ else: _comfy_api: Any = None if TYPE_CHECKING else import_module("comfy_api.latest") _comfy_io: Any = None if TYPE_CHECKING else _comfy_api.io -_comfy_ui: Any = None if TYPE_CHECKING else _comfy_api.UI class LoadMaskBatchV3(_ComfyNodeBase): @@ -47,7 +46,6 @@ class LoadMaskBatchV3(_ComfyNodeBase): "single mask batch." ), search_aliases=["load masks", "mask batch", "regional masks"], - has_intermediate_output=True, inputs=[ _comfy_io.MultiCombo.Input( "image", @@ -85,13 +83,10 @@ class LoadMaskBatchV3(_ComfyNodeBase): @classmethod def execute(cls, image: str | list[str], channel: str) -> Any: - """Load the selected files and return a native mask-batch preview.""" + """Load the selected files as one same-sized mask batch.""" mask_batch = cls.service_class().load(image, channel) - return _comfy_io.NodeOutput( - mask_batch, - ui=_comfy_ui.PreviewMask(mask_batch, cls=cls), - ) + return _comfy_io.NodeOutput(mask_batch) @classmethod def validate_inputs(cls, image: str | list[str], channel: str) -> bool | str: diff --git a/simple_syrup/runtime/image_file_loader.py b/simple_syrup/runtime/image_file_loader.py new file mode 100644 index 0000000..8bfe401 --- /dev/null +++ b/simple_syrup/runtime/image_file_loader.py @@ -0,0 +1,80 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ComfyUI filesystem adapter for one authored image file.""" + +from __future__ import annotations + +import hashlib +from importlib import import_module +from pathlib import Path + +import torch +from PIL import Image + + +class ImageFileLoader: + """Load one static image through ComfyUI's native image implementation.""" + + def available_files(self) -> tuple[str, ...]: + """Return choices declared by ComfyUI's native image loader.""" + + declaration = import_module("nodes").LoadImage.INPUT_TYPES() + image_input = declaration["required"]["image"] + choices = image_input[0] + if not isinstance(choices, (list, tuple)): + raise TypeError("ComfyUI LoadImage returned invalid image choices.") + return tuple(str(value) for value in choices) + + def validate(self, annotated_path: str) -> None: + """Validate one path with ComfyUI's native image-loader contract.""" + + result = import_module("nodes").LoadImage.VALIDATE_INPUTS(annotated_path) + if result is not True: + raise ValueError(str(result)) + + def load(self, annotated_path: str) -> torch.Tensor: + """Return exactly one BHWC image from a validated annotated path.""" + + self.validate(annotated_path) + path = self._resolve_path(annotated_path) + with Image.open(path) as image: + frame_count = int(getattr(image, "n_frames", 1)) + if frame_count != 1: + raise ValueError( + f"Load Image List requires one image per file; {annotated_path!r} " + f"contains {frame_count} frames." + ) + + load_image = import_module("nodes").LoadImage() + result = load_image.load_image(annotated_path) + image = result[0] + if not isinstance(image, torch.Tensor): + raise TypeError(f"ComfyUI did not return an IMAGE for {annotated_path!r}.") + if image.ndim != 4 or int(image.shape[0]) != 1: + raise ValueError( + f"Load Image List requires one image per file; {annotated_path!r} " + f"returned shape {tuple(image.shape)}." + ) + return image + + def fingerprint(self, annotated_path: str) -> str: + """Return the content fingerprint for one validated image file.""" + + path = self._resolve_path(annotated_path) + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def _resolve_path(self, annotated_path: str) -> Path: + """Resolve an existing Comfy annotated path without arbitrary file IO.""" + + if not isinstance(annotated_path, str) or not annotated_path: + raise ValueError("Load Image List requires a non-empty image file path.") + folder_paths = import_module("folder_paths") + if not folder_paths.exists_annotated_filepath(annotated_path): + raise ValueError(f"Image file does not exist: {annotated_path!r}.") + return Path(folder_paths.get_annotated_filepath(annotated_path)) diff --git a/simple_syrup/runtime/seg_preview_assets.py b/simple_syrup/runtime/seg_preview_assets.py index a78255f..9785889 100644 --- a/simple_syrup/runtime/seg_preview_assets.py +++ b/simple_syrup/runtime/seg_preview_assets.py @@ -6,6 +6,7 @@ from __future__ import annotations +from dataclasses import dataclass from importlib import import_module from typing import Any, Protocol @@ -16,23 +17,34 @@ from ..services.simple_preview_segs_service import SegPreviewDocument SEG_PREVIEW_UI_KEY = "simple_syrup_segs_preview" +@dataclass(frozen=True) +class SegPreviewPublication: + """Carry native gallery images beside overlay interaction metadata.""" + + manifest: dict[str, object] + images: tuple[dict[str, str], ...] + + class SegPreviewAssetPublisher(Protocol): """Publish one backend preview document as a JSON-compatible UI payload.""" - def publish(self, document: SegPreviewDocument) -> dict[str, object]: - """Return a frontend-ready manifest with temporary asset references.""" + def publish(self, document: SegPreviewDocument) -> SegPreviewPublication: + """Return native gallery references and overlay interaction metadata.""" class ComfySegPreviewAssetPublisher: """Store preview tensors using ComfyUI's authoritative image helper.""" - def publish(self, document: SegPreviewDocument) -> dict[str, object]: - """Return one versioned manifest referencing a base image and mask atlas.""" + def publish(self, document: SegPreviewDocument) -> SegPreviewPublication: + """Publish native RGBA regions plus the overlay image and mask atlas.""" comfy_api: Any = import_module("comfy_api.latest") image_ref = _publish_one(comfy_api, document.image) atlas_ref = _publish_one(comfy_api, document.atlas) - return { + region_refs = tuple( + _publish_one(comfy_api, image) for image in document.region_images + ) + manifest: dict[str, object] = { "version": 1, "source": { "width": document.source_width, @@ -72,6 +84,7 @@ class ComfySegPreviewAssetPublisher: for region in document.regions ], } + return SegPreviewPublication(manifest=manifest, images=region_refs) def _publish_one(comfy_api: Any, image: torch.Tensor) -> dict[str, str]: diff --git a/simple_syrup/services/load_image_list_service.py b/simple_syrup/services/load_image_list_service.py new file mode 100644 index 0000000..d7311bd --- /dev/null +++ b/simple_syrup/services/load_image_list_service.py @@ -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 + +"""Application service for ordered authored-image list loading.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import ClassVar + +import torch + +from ..domain.ordered_files import OrderedFileSelection +from ..runtime.image_file_loader import ImageFileLoader +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + + +class LoadImageListService: + """Load ordered files as independently sized singleton IMAGE tensors.""" + + loader_class: ClassVar[type[ImageFileLoader]] = ImageFileLoader + + def validate(self, files: Sequence[str]) -> None: + """Validate every selected file through Comfy's native image loader.""" + + ordered_files = self._selection(files).paths + loader = self.loader_class() + for path in ordered_files: + loader.validate(path) + + def load(self, files: Sequence[str]) -> list[torch.Tensor]: + """Load every authored position without batching or resizing images.""" + + ordered_files = self._selection(files).paths + loader = self.loader_class() + images = [loader.load(path) for path in ordered_files] + LOGGER.info( + "Authored image list loaded", + extra={ + "operation": "load_image_list", + "image_count": len(images), + "image_shapes": [tuple(image.shape) for image in images], + }, + ) + return images + + def fingerprint(self, files: Sequence[str]) -> str: + """Return an order-sensitive fingerprint for all selected images.""" + + loader = self.loader_class() + return self._selection(files).fingerprint(loader.fingerprint) + + def available_files(self) -> tuple[str, ...]: + """Return Comfy input images eligible for selection.""" + + return self.loader_class().available_files() + + def _selection(self, files: Sequence[str]) -> OrderedFileSelection: + """Return validated positional image-file state.""" + + return OrderedFileSelection.require( + files, + node_name="Load Image List", + item_name="image", + ) diff --git a/simple_syrup/services/load_mask_batch_service.py b/simple_syrup/services/load_mask_batch_service.py index 3fd4106..8e31eae 100644 --- a/simple_syrup/services/load_mask_batch_service.py +++ b/simple_syrup/services/load_mask_batch_service.py @@ -6,12 +6,12 @@ from __future__ import annotations -import hashlib from collections.abc import Sequence from typing import ClassVar import torch +from ..domain.ordered_files import OrderedFileSelection from ..runtime.mask_file_loader import MaskFileLoader from ..shared.logging import get_logger @@ -26,7 +26,7 @@ class LoadMaskBatchService: def validate(self, files: Sequence[str], channel: str) -> None: """Validate every selected file through the native Comfy mask loader.""" - ordered_files = self._validate_files(files) + ordered_files = self._selection(files).paths loader = self.loader_class() for path in ordered_files: loader.validate(path, channel) @@ -59,39 +59,29 @@ class LoadMaskBatchService: def load_each(self, files: Sequence[str], channel: str) -> tuple[torch.Tensor, ...]: """Load ordered masks without requiring batch-compatible dimensions.""" - ordered_files = self._validate_files(files) + ordered_files = self._selection(files).paths loader = self.loader_class() return tuple(loader.load(path, channel) for path in ordered_files) def fingerprint(self, files: Sequence[str], channel: str) -> str: """Return an order-sensitive fingerprint for files and channel.""" - ordered_files = self._validate_files(files) loader = self.loader_class() - digest = hashlib.sha256() - digest.update(channel.encode("utf-8")) - for path in ordered_files: - encoded_path = path.encode("utf-8") - digest.update(len(encoded_path).to_bytes(8, "big")) - digest.update(encoded_path) - digest.update(loader.fingerprint(path).encode("ascii")) - return digest.hexdigest() + return self._selection(files).fingerprint( + loader.fingerprint, + context=(channel,), + ) def available_files(self) -> tuple[str, ...]: """Return Comfy input images eligible for selection.""" return self.loader_class().available_files() - def _validate_files(self, files: Sequence[str]) -> tuple[str, ...]: - """Return a non-empty ordered immutable file sequence.""" + def _selection(self, files: Sequence[str]) -> OrderedFileSelection: + """Return validated positional mask-file state.""" - ordered: tuple[str, ...] - if isinstance(files, str): - ordered = (files,) - else: - ordered = tuple(files) - if not ordered: - raise ValueError("Load Mask Batch requires at least one mask file.") - if any(not isinstance(path, str) or not path for path in ordered): - raise TypeError("Load Mask Batch mask files must be non-empty strings.") - return ordered + return OrderedFileSelection.require( + files, + node_name="Load Mask Batch", + item_name="mask", + ) diff --git a/simple_syrup/services/simple_preview_segs_service.py b/simple_syrup/services/simple_preview_segs_service.py index e574d33..dd47cc4 100644 --- a/simple_syrup/services/simple_preview_segs_service.py +++ b/simple_syrup/services/simple_preview_segs_service.py @@ -59,6 +59,7 @@ class SegPreviewDocument: preview_height: int image: torch.Tensor atlas: torch.Tensor + region_images: tuple[torch.Tensor, ...] regions: tuple[SegPreviewRegion, ...] @@ -88,6 +89,7 @@ class SimplePreviewSEGSService: height=preview_height, ) atlas, regions = _build_atlas(plan) + region_images = _build_region_images(source_image, plan) return SegPreviewDocument( source_width=image_width, source_height=image_height, @@ -95,10 +97,33 @@ class SimplePreviewSEGSService: preview_height=preview_height, image=preview_image, atlas=atlas, + region_images=region_images, regions=regions, ) +def _build_region_images( + source_image: torch.Tensor, + plan: SegVisualizationPlan, +) -> tuple[torch.Tensor, ...]: + """Render bounded RGBA region crops for Comfy's native image gallery.""" + + images: list[torch.Tensor] = [] + for region in plan.regions: + crop = region.crop_region + texture = source_image[:, crop.top : crop.bottom, crop.left : crop.right, :3] + width, height = _fit_dimensions(crop.width, crop.height, _MAX_PREVIEW_EDGE) + texture = _resize_image(texture, width=width, height=height) + alpha = functional.interpolate( + region.mask.unsqueeze(0).unsqueeze(0).float(), + size=(height, width), + mode="bilinear", + align_corners=False, + ).movedim(1, -1) + images.append(torch.cat((texture, alpha.clamp(0.0, 1.0)), dim=-1)) + return tuple(images) + + def _build_atlas( plan: SegVisualizationPlan, ) -> tuple[torch.Tensor, tuple[SegPreviewRegion, ...]]: diff --git a/tests/test_image_file_loader.py b/tests/test_image_file_loader.py new file mode 100644 index 0000000..b844d07 --- /dev/null +++ b/tests/test_image_file_loader.py @@ -0,0 +1,123 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Comfy authored-image file adapter.""" + +from __future__ import annotations + +from importlib import import_module +from pathlib import Path + +import pytest +import torch +from PIL import Image + +from simple_syrup.runtime.image_file_loader import ImageFileLoader + + +def patch_path( + monkeypatch: pytest.MonkeyPatch, + path: Path, + *, + exists: bool = True, +) -> None: + """Route Comfy annotated-path helpers to one temporary file.""" + + folder_paths = import_module("folder_paths") + monkeypatch.setattr(import_module("nodes"), "folder_paths", folder_paths) + monkeypatch.setattr(folder_paths, "exists_annotated_filepath", lambda value: exists) + monkeypatch.setattr(folder_paths, "get_annotated_filepath", lambda value: str(path)) + + +def test_load_uses_native_image_loader_and_returns_one_singleton( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Static files are decoded through Comfy's LoadImage behavior.""" + + path = tmp_path / "image.png" + Image.new("RGB", (3, 2), color=(255, 0, 0)).save(path) + patch_path(monkeypatch, path) + calls: list[str] = [] + + class FakeNativeLoader: + """Return one recognizable native image.""" + + @classmethod + def VALIDATE_INPUTS(cls, value: str) -> bool: + """Accept the annotated path through the native contract.""" + + del cls, value + return True + + def load_image(self, value: str) -> tuple[torch.Tensor, torch.Tensor]: + """Record the native loader argument.""" + + calls.append(value) + return torch.full((1, 2, 3, 3), 0.75), torch.zeros((1, 2, 3)) + + monkeypatch.setattr(import_module("nodes"), "LoadImage", FakeNativeLoader) + + result = ImageFileLoader().load("image.png") + + assert calls == ["image.png"] + assert tuple(result.shape) == (1, 2, 3, 3) + assert torch.all(result == 0.75) + + +def test_available_files_uses_native_image_loader_choices( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The node's file combo is sourced from native LoadImage inputs.""" + + class FakeNativeLoader: + """Expose recognizable native widget choices.""" + + @classmethod + def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[list[str], dict[str, bool]]]]: + """Return the native image-upload declaration shape.""" + + del cls + return {"required": {"image": (["z.png", "a.png"], {"image_upload": True})}} + + monkeypatch.setattr(import_module("nodes"), "LoadImage", FakeNativeLoader) + + assert ImageFileLoader().available_files() == ("z.png", "a.png") + + +def test_rejects_multiframe_and_missing_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """One selected file cannot silently expand into several list items.""" + + path = tmp_path / "animated.gif" + first = Image.new("RGB", (2, 2), color=0) + second = Image.new("RGB", (2, 2), color=255) + first.save(path, save_all=True, append_images=[second]) + patch_path(monkeypatch, path) + loader = ImageFileLoader() + + with pytest.raises(ValueError, match="contains 2 frames"): + loader.load("animated.gif") + + patch_path(monkeypatch, path, exists=False) + with pytest.raises(ValueError, match="does not exist"): + loader.fingerprint("missing.png") + + +def test_fingerprint_reads_validated_file_contents( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Content changes invalidate the image-list node cache.""" + + path = tmp_path / "image.png" + path.write_bytes(b"first") + patch_path(monkeypatch, path) + loader = ImageFileLoader() + first = loader.fingerprint("image.png") + path.write_bytes(b"second") + + assert loader.fingerprint("image.png") != first diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py index e59d00a..9c418a1 100644 --- a/tests/test_license_headers.py +++ b/tests/test_license_headers.py @@ -71,6 +71,30 @@ def test_direct_vendored_and_generated_source_are_excluded() -> None: assert should_update(Path("simple_syrup/runtime/a1111_sampling.py")) +def test_tracked_source_discovery_ignores_worktree_deletions( + tmp_path: Path, + monkeypatch: Any, +) -> None: + """Ignore tracked source paths removed from the working tree.""" + + deleted = tmp_path / "deleted.py" + present = tmp_path / "present.py" + present.write_text("# source", encoding="utf-8") + + class Result: + """Represent deterministic git output for the loaded tool module.""" + + stdout = f"{deleted}\n{present}\n" + + monkeypatch.setattr( + license_headers.subprocess, + "run", + lambda *args, **kwargs: Result(), + ) + + assert license_headers._tracked_source_files() == [present] + + def test_tracked_first_party_source_has_project_license_header() -> None: """Require tracked SimpleSyrup-owned source to carry the AGPL project header.""" diff --git a/tests/test_load_image_list_service.py b/tests/test_load_image_list_service.py new file mode 100644 index 0000000..6769ece --- /dev/null +++ b/tests/test_load_image_list_service.py @@ -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 + +"""Tests for ordered authored-image list loading orchestration.""" + +from __future__ import annotations + +from typing import ClassVar + +import pytest +import torch + +from simple_syrup.runtime.image_file_loader import ImageFileLoader +from simple_syrup.services.load_image_list_service import LoadImageListService + + +class FakeImageFileLoader(ImageFileLoader): + """Provide deterministic images and fingerprints for service tests.""" + + images: ClassVar[dict[str, torch.Tensor]] = {} + loaded: ClassVar[list[str]] = [] + validated: ClassVar[list[str]] = [] + + def available_files(self) -> tuple[str, ...]: + """Return deterministic selectable paths.""" + + return ("first.png", "second.png") + + def load(self, path: str) -> torch.Tensor: + """Record and return one configured singleton image.""" + + self.loaded.append(path) + return self.images[path] + + def validate(self, path: str) -> None: + """Record validation through the runtime boundary.""" + + self.validated.append(path) + + def fingerprint(self, path: str) -> str: + """Return a recognizable per-path fingerprint.""" + + return f"digest:{path}" + + +class FakeLoadImageListService(LoadImageListService): + """Use the fake runtime adapter in application-service tests.""" + + loader_class = FakeImageFileLoader + + +def configured_service(images: dict[str, torch.Tensor]) -> FakeLoadImageListService: + """Return a service configured with fake ordered images.""" + + FakeImageFileLoader.images = images + FakeImageFileLoader.loaded = [] + FakeImageFileLoader.validated = [] + return FakeLoadImageListService() + + +def test_loads_different_sized_images_as_ordered_singletons() -> None: + """Image list members retain independent dimensions and authored order.""" + + service = configured_service( + { + "wide.png": torch.full((1, 2, 4, 3), 0.8), + "tall.png": torch.full((1, 5, 2, 3), 0.2), + } + ) + + images = service.load(["wide.png", "tall.png"]) + + assert isinstance(images, list) + assert [tuple(image.shape) for image in images] == [(1, 2, 4, 3), (1, 5, 2, 3)] + assert torch.all(images[0] == 0.8) + assert torch.all(images[1] == 0.2) + assert FakeImageFileLoader.loaded == ["wide.png", "tall.png"] + + +def test_preserves_duplicate_positions_and_rejects_empty_selection() -> None: + """The list is positional rather than a set and must contain an image.""" + + service = configured_service({"same.png": torch.ones((1, 2, 2, 3))}) + + images = service.load(["same.png", "same.png"]) + + assert len(images) == 2 + assert FakeImageFileLoader.loaded == ["same.png", "same.png"] + with pytest.raises(ValueError, match="at least one image file"): + service.load([]) + + +def test_validation_and_fingerprint_are_order_sensitive() -> None: + """Validation and caching preserve every selected position.""" + + service = configured_service({}) + + service.validate(["second.png", "first.png"]) + first = service.fingerprint(["first.png", "second.png"]) + reordered = service.fingerprint(["second.png", "first.png"]) + + assert FakeImageFileLoader.validated == ["second.png", "first.png"] + assert first != reordered + assert service.available_files() == ("first.png", "second.png") diff --git a/tests/test_load_image_list_v3_node.py b/tests/test_load_image_list_v3_node.py new file mode 100644 index 0000000..1b981ea --- /dev/null +++ b/tests/test_load_image_list_v3_node.py @@ -0,0 +1,107 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Load Image List Comfy v3 node.""" + +from __future__ import annotations + +from typing import ClassVar + +import torch + +from simple_syrup.nodes_v3.load_image_list import LoadImageListV3 + + +class FakeService: + """Provide deterministic image-list behavior at the node boundary.""" + + loaded: ClassVar[list[str] | None] = None + + def available_files(self) -> tuple[str, ...]: + """Return node combo choices.""" + + return ("b.png", "a.png") + + def validate(self, files: str | list[str]) -> None: + """Accept deterministic widget values.""" + + del files + + def load(self, files: str | list[str]) -> list[torch.Tensor]: + """Record order and return independently sized singleton images.""" + + ordered = [files] if isinstance(files, str) else files + type(self).loaded = ordered + return [torch.zeros((1, 2, 3, 3)), torch.ones((1, 4, 2, 3))] + + def fingerprint(self, files: str | list[str]) -> str: + """Return an order-visible node fingerprint.""" + + ordered = [files] if isinstance(files, str) else files + return "|".join(ordered) + + +def test_schema_exposes_native_ordered_image_upload_and_list_output() -> None: + """The widget persists filenames while the IMAGE socket is a Comfy list.""" + + original = LoadImageListV3.service_class + LoadImageListV3.service_class = FakeService # type: ignore[assignment] + try: + schema = LoadImageListV3.define_schema() + finally: + LoadImageListV3.service_class = original + + inputs = {value.id: value for value in schema.inputs} + image = inputs["image"].as_dict() + assert schema.node_id == "SimpleSyrup.LoadImageList" + assert schema.display_name == "Load Image List" + assert schema.has_intermediate_output is False + assert image["image_upload"] is True + assert image["allow_batch"] is True + assert image["image_folder"] == "input" + assert image["multiselect"] is True + assert image["default"] == [] + assert schema.outputs[0].io_type == "IMAGE" + assert schema.outputs[0].is_output_list is True + + +def test_execute_returns_exact_ordered_python_list_without_batching() -> None: + """Comfy receives one singleton tensor for every ordered image position.""" + + original = LoadImageListV3.service_class + LoadImageListV3.service_class = FakeService # type: ignore[assignment] + try: + output = LoadImageListV3.execute(["b.png", "a.png"]) + fingerprint = LoadImageListV3.fingerprint_inputs(["b.png", "a.png"]) + finally: + LoadImageListV3.service_class = original + + assert FakeService.loaded == ["b.png", "a.png"] + assert output.result is not None + images = output.result[0] + assert isinstance(images, list) + assert [tuple(image.shape) for image in images] == [(1, 2, 3, 3), (1, 4, 2, 3)] + assert fingerprint == "b.png|a.png" + + +def test_validation_surfaces_actionable_service_failures() -> None: + """Comfy validation receives the service's workflow-facing reason.""" + + class RejectingService(FakeService): + """Reject an empty image list.""" + + def validate(self, files: str | list[str]) -> None: + """Raise one deterministic validation failure.""" + + del files + raise ValueError("Load Image List requires at least one image file.") + + original = LoadImageListV3.service_class + LoadImageListV3.service_class = RejectingService # type: ignore[assignment] + try: + result: bool | str = LoadImageListV3.validate_inputs([]) + finally: + LoadImageListV3.service_class = original + + assert result == "Load Image List requires at least one image file." diff --git a/tests/test_load_mask_batch_v3_node.py b/tests/test_load_mask_batch_v3_node.py index 7014307..117f216 100644 --- a/tests/test_load_mask_batch_v3_node.py +++ b/tests/test_load_mask_batch_v3_node.py @@ -6,11 +6,10 @@ from __future__ import annotations -from typing import Any, ClassVar +from typing import ClassVar import torch -from simple_syrup.nodes_v3 import load_mask_batch as node_module from simple_syrup.nodes_v3.load_mask_batch import LoadMaskBatchV3 @@ -55,7 +54,7 @@ def test_schema_exposes_native_ordered_mask_multiselect() -> None: image = inputs["image"].as_dict() assert schema.node_id == "SimpleSyrup.LoadMaskBatch" assert schema.display_name == "Load Mask Batch" - assert schema.has_intermediate_output is True + assert schema.has_intermediate_output is False assert image["image_upload"] is True assert image["allow_batch"] is True assert image["image_folder"] == "input" @@ -69,20 +68,11 @@ def test_schema_exposes_native_ordered_mask_multiselect() -> None: assert [output.io_type for output in schema.outputs] == ["MASK"] -def test_execute_preserves_order_and_returns_native_preview(monkeypatch: Any) -> None: - """Execution returns one MASK batch and previews that exact tensor.""" - - previews: list[tuple[torch.Tensor, object]] = [] - - def fake_preview(mask: torch.Tensor, cls: object) -> str: - """Record the native preview payload.""" - - previews.append((mask, cls)) - return "preview" +def test_execute_preserves_order_and_returns_one_mask_batch() -> None: + """Execution returns one MASK batch without adding a duplicate UI gallery.""" original = LoadMaskBatchV3.service_class LoadMaskBatchV3.service_class = FakeService # type: ignore[assignment] - monkeypatch.setattr(node_module._comfy_ui, "PreviewMask", fake_preview) try: output = LoadMaskBatchV3.execute(["b.png", "a.png"], "green") fingerprint = LoadMaskBatchV3.fingerprint_inputs(["b.png", "a.png"], "green") @@ -93,8 +83,7 @@ def test_execute_preserves_order_and_returns_native_preview(monkeypatch: Any) -> assert output.result is not None mask_batch = output.result[0] assert mask_batch.shape == (2, 2, 2) - assert previews == [(mask_batch, LoadMaskBatchV3)] - assert output.ui == "preview" + assert output.ui is None assert fingerprint == "green:b.png|a.png" diff --git a/tests/test_ordered_files.py b/tests/test_ordered_files.py new file mode 100644 index 0000000..afc9ec4 --- /dev/null +++ b/tests/test_ordered_files.py @@ -0,0 +1,61 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for ordered workflow-file domain state.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.ordered_files import OrderedFileSelection + + +def test_preserves_scalar_duplicate_and_positional_state() -> None: + """Workflow file selections are ordered multisets rather than sets.""" + + scalar = OrderedFileSelection.require( + "one.png", + node_name="Loader", + item_name="image", + ) + duplicates = OrderedFileSelection.require( + ["same.png", "middle.png", "same.png"], + node_name="Loader", + item_name="image", + ) + + assert scalar.paths == ("one.png",) + assert duplicates.paths == ("same.png", "middle.png", "same.png") + + +def test_rejects_empty_and_malformed_workflow_values() -> None: + """Invalid persisted values fail with workflow-facing context.""" + + with pytest.raises(ValueError, match="Loader requires at least one image file"): + OrderedFileSelection.require([], node_name="Loader", item_name="image") + with pytest.raises(TypeError, match="image files must be non-empty strings"): + OrderedFileSelection.require( + ["valid.png", ""], + node_name="Loader", + item_name="image", + ) + + +def test_fingerprint_changes_with_order_duplicates_contents_and_context() -> None: + """Every execution-relevant positional property participates in caching.""" + + content = {"a.png": "aaa", "b.png": "bbb"} + first = OrderedFileSelection(("a.png", "b.png")) + reordered = OrderedFileSelection(("b.png", "a.png")) + duplicate = OrderedFileSelection(("a.png", "a.png")) + + assert first.fingerprint(content.__getitem__) != reordered.fingerprint( + content.__getitem__ + ) + assert first.fingerprint(content.__getitem__) != duplicate.fingerprint( + content.__getitem__ + ) + assert first.fingerprint( + content.__getitem__, context=("red",) + ) != first.fingerprint(content.__getitem__, context=("blue",)) diff --git a/tests/test_regional_prompt_workflow.py b/tests/test_regional_prompt_workflow.py index 97dcbb9..a9ac039 100644 --- a/tests/test_regional_prompt_workflow.py +++ b/tests/test_regional_prompt_workflow.py @@ -13,7 +13,6 @@ import torch from simple_syrup.domain.conditioning_batch import ConditioningBatch from simple_syrup.nodes.encode_prompt_batch import EncodePromptBatch -from simple_syrup.nodes_v3 import load_mask_batch as load_node_module from simple_syrup.nodes_v3.ksampler_prompt_by_region import ( KSamplerPromptByRegionV3, ) @@ -85,9 +84,6 @@ def test_sep_prompts_and_disk_masks_flow_into_regional_sampler( monkeypatch.setattr(EncodePromptBatch, "encoder_class", WorkflowEncoder) monkeypatch.setattr(LoadMaskBatchV3, "service_class", WorkflowMaskLoader) - monkeypatch.setattr( - load_node_module._comfy_ui, "PreviewMask", lambda mask, cls: None - ) monkeypatch.setattr( KSamplerPromptByRegionV3, "sampling_service_class", diff --git a/tests/test_registration.py b/tests/test_registration.py index 3f3ed5c..a37a69d 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -38,6 +38,7 @@ BASE_NODE_IDS = [ "SimpleSyrup.LatentDiagnostics", "SimpleSyrup.LayerStyleSAMModelsAdapter", "SimpleSyrup.LoadUltralyticsModel", + "SimpleSyrup.LoadImageList", "SimpleSyrup.LoadMaskBatch", "SimpleSyrup.MaskToSEGS", "SimpleSyrup.PromptEncodeStyleAndNormalization", diff --git a/tests/test_seg_preview_assets.py b/tests/test_seg_preview_assets.py index bcffdb0..bb62d3f 100644 --- a/tests/test_seg_preview_assets.py +++ b/tests/test_seg_preview_assets.py @@ -58,9 +58,20 @@ def test_publisher_stores_both_assets_and_builds_versioned_manifest( document = _document() manifest = ComfySegPreviewAssetPublisher().publish(document) - assert _PreviewImage.calls == [document.image, document.atlas] - assert manifest["version"] == 1 - assert manifest["preview"] == { + assert _PreviewImage.calls == [ + document.image, + document.atlas, + *document.region_images, + ] + assert manifest.images == ( + { + "filename": "asset-3.png", + "subfolder": "preview", + "type": "temp", + }, + ) + assert manifest.manifest["version"] == 1 + assert manifest.manifest["preview"] == { "width": 4, "height": 3, "image": { @@ -69,7 +80,7 @@ def test_publisher_stores_both_assets_and_builds_versioned_manifest( "type": "temp", }, } - assert manifest["atlas"] == { + assert manifest.manifest["atlas"] == { "width": 2, "height": 1, "image": { @@ -78,7 +89,7 @@ def test_publisher_stores_both_assets_and_builds_versioned_manifest( "type": "temp", }, } - assert manifest["regions"] == [ + assert manifest.manifest["regions"] == [ { "id": "seg-0001", "index": 0, @@ -128,6 +139,7 @@ def _document() -> SegPreviewDocument: preview_height=3, image=torch.zeros((1, 3, 4, 3)), atlas=torch.ones((1, 1, 2, 3)), + region_images=(torch.zeros((1, 3, 2, 4)),), regions=( SegPreviewRegion( region_id="seg-0001", diff --git a/tests/test_simple_preview_segs_node.py b/tests/test_simple_preview_segs_node.py index 3732866..4d4132c 100644 --- a/tests/test_simple_preview_segs_node.py +++ b/tests/test_simple_preview_segs_node.py @@ -12,6 +12,7 @@ import torch from pytest import MonkeyPatch from simple_syrup.nodes.simple_preview_segs import SimplePreviewSEGS +from simple_syrup.runtime.seg_preview_assets import SegPreviewPublication from simple_syrup.services.simple_preview_segs_service import SegPreviewDocument @@ -39,6 +40,7 @@ def test_node_publishes_manifest_and_returns_original_segs_object( preview_height=4, image=torch.zeros((1, 4, 5, 3)), atlas=torch.zeros((1, 1, 1, 3)), + region_images=(), regions=(), ) @@ -57,11 +59,14 @@ def test_node_publishes_manifest_and_returns_original_segs_object( published: ClassVar[list[SegPreviewDocument]] = [] - def publish(self, value: SegPreviewDocument) -> dict[str, object]: + def publish(self, value: SegPreviewDocument) -> SegPreviewPublication: """Record and publish the document.""" self.published.append(value) - return {"version": 1, "regions": []} + return SegPreviewPublication( + manifest={"version": 1, "regions": []}, + images=({"filename": "region.png", "subfolder": "", "type": "temp"},), + ) node = SimplePreviewSEGS() monkeypatch.setattr(SimplePreviewSEGS, "service_class", _Service) @@ -74,6 +79,7 @@ def test_node_publishes_manifest_and_returns_original_segs_object( assert node_result == (segs,) assert node_result[0] is segs assert result["ui"] == { - "simple_syrup_segs_preview": [{"version": 1, "regions": []}] + "images": [{"filename": "region.png", "subfolder": "", "type": "temp"}], + "simple_syrup_segs_preview": [{"version": 1, "regions": []}], } assert _Publisher.published == [document] diff --git a/tests/test_simple_preview_segs_service.py b/tests/test_simple_preview_segs_service.py index 1394302..dab68ac 100644 --- a/tests/test_simple_preview_segs_service.py +++ b/tests/test_simple_preview_segs_service.py @@ -29,6 +29,11 @@ def test_service_builds_bounded_image_and_non_overlapping_mask_atlas() -> None: assert document.image.shape == (1, 512, 1024, 3) assert document.atlas.ndim == 4 assert int(document.atlas.shape[-1]) == 3 + assert [tuple(image.shape) for image in document.region_images] == [ + (1, 400, 600, 4), + (1, 600, 600, 4), + ] + assert torch.all(document.region_images[0][..., 3] == 1.0) assert [region.label for region in document.regions] == ["subject", "clothing"] first_atlas, second_atlas = (region.atlas for region in document.regions) assert ( @@ -49,9 +54,37 @@ def test_service_returns_minimal_assets_for_empty_segs() -> None: assert document.image.shape == (1, 8, 12, 3) assert document.atlas.shape == (1, 1, 1, 3) + assert document.region_images == () assert document.regions == () +def test_service_preserves_region_texture_with_transparent_non_mask_area() -> None: + """Native inspection assets expose only the selected SEG through alpha.""" + + mask = torch.zeros((4, 4)) + mask[1:3, 1:3] = 1.0 + region = CropRegion(0, 0, 4, 4) + segment = Segment( + cropped_image=None, + cropped_mask=mask, + confidence=0.9, + crop_region=region, + bbox=BoundingBox(*region), + label="subject", + ) + + document = SimplePreviewSEGSService().build( + image=torch.full((1, 4, 4, 3), 0.75), + segs=((4, 4), (segment,)), + ) + + inspection = document.region_images[0] + assert inspection.shape == (1, 4, 4, 4) + assert torch.all(inspection[..., :3] == 0.75) + assert inspection[0, 0, 0, 3] == 0.0 + assert inspection[0, 1, 1, 3] == 1.0 + + def test_service_rejects_image_and_segs_size_mismatch() -> None: """Hit testing cannot proceed against ambiguous source coordinates.""" diff --git a/tools/add_license_headers.py b/tools/add_license_headers.py index 7ae0860..2effdd6 100644 --- a/tools/add_license_headers.py +++ b/tools/add_license_headers.py @@ -84,7 +84,7 @@ def _tracked_source_files() -> list[Path]: return sorted( path for path_text in result.stdout.splitlines() - if _should_update(path := Path(path_text)) + if _should_update(path := Path(path_text)) and path.is_file() ) diff --git a/web/dist/simple-syrup.js b/web/dist/simple-syrup.js index ef1ae03..fdd2338 100644 --- a/web/dist/simple-syrup.js +++ b/web/dist/simple-syrup.js @@ -548,122 +548,684 @@ function registerExternalLLMRefreshHook(app2, api = { refreshExternalLLMModels } }; } -// web/src/maskBatchPreview.ts -var EMPTY_PREVIEW = { - images: [], - animated: [] -}; -var PREVIEW_PROMPT_ID = "simple-syrup-mask-batch-preview"; -var MaskBatchPreviewController = class { - constructor(executionEvents, node, loadPreview = getMaskBatchPreview, logger = console, clearNativePreview2 = () => void 0) { - this.executionEvents = executionEvents; +// web/src/nativeNodePreview.ts +var NativeNodePreview = class { + constructor(app2, api, node) { + this.app = app2; + this.api = api; this.node = node; - this.loadPreview = loadPreview; - this.logger = logger; - this.clearNativePreview = clearNativePreview2; } - executionEvents; + app; + api; node; - loadPreview; - logger; - clearNativePreview; - requestVersion = 0; - /** Replace the visible preview with the exact selected-channel mask output. */ - refresh(files, channel) { - if (this.node.id === void 0 || channel === void 0) return; - const requestVersion = ++this.requestVersion; - this.clearNativePreview(); - this.publish(EMPTY_PREVIEW); - if (files.length === 0) return; - void this.loadPreview([...files], channel).then((output) => { - if (requestVersion === this.requestVersion) this.publish(output); - }).catch((error) => { - if (requestVersion !== this.requestVersion) return; - this.logger.warn("Could not refresh Load Mask Batch preview.", error); - }); - } - /** Publish an execution-shaped result through Comfy's native preview. */ + publishListeners = /* @__PURE__ */ new Set(); + /** Publish media as an execution-shaped output for both node renderers. */ publish(output) { - if (this.node.id === void 0) return; - this.executionEvents.dispatchEvent( + const nodeId = this.nodeId(); + if (!nodeId) return; + this.app.nodeOutputs ??= {}; + this.app.nodeOutputs[nodeId] = output; + this.api.dispatchEvent( new CustomEvent("executed", { - detail: { - node: String(this.node.id), - output, - merge: false, - prompt_id: PREVIEW_PROMPT_ID - } + detail: { node: nodeId, display_node: nodeId, output } }) ); - this.node.graph?.setDirtyCanvas?.(true, true); + for (const listener of this.publishListeners) listener(); + } + /** Notify renderer adapters after native output changes. */ + subscribe(listener) { + this.publishListeners.add(listener); + return () => this.publishListeners.delete(listener); + } + /** Remove loader-owned media from Comfy's native preview surface. */ + clear() { + this.publish({ images: [], animated: [] }); + } + nodeId() { + if (this.node.id === void 0) return void 0; + return String(this.node.id); } }; -// web/src/maskBatchUpload.ts -var LOAD_MASK_BATCH_NODE_ID = "SimpleSyrup.LoadMaskBatch"; -var REPLACE_MASKS_LABEL = "Replace masks..."; -var ADD_MASKS_LABEL = "Add masks..."; -var REMOVE_MASK_LABEL = "Remove selected mask"; -var EMPTY_MASK_SELECTION_LABEL = "No masks loaded"; -function registerMaskBatchUpload(app2, executionEvents, loadPreview, logger = console) { - const extension = { - name: "SimpleSyrup.LoadMaskBatchUpload", - nodeCreated(node) { - try { - configureMaskBatchNode(node, executionEvents, loadPreview, logger, app2); - } catch (error) { - logger.warn( - `Could not configure Load Mask Batch native controls: ${errorMessage2(error)}`, - error +// web/src/orderedMediaPreview.ts +var OrderedMediaPreviewController = class { + constructor(target, label, logger = console) { + this.target = target; + this.label = label; + this.logger = logger; + } + target; + label; + logger; + requestVersion = 0; + disposed = false; + hasPreview = false; + /** Resolve one ordered selection without allowing stale previews to win. */ + refresh(files, loadPreview) { + if (this.disposed) return; + const requestVersion = ++this.requestVersion; + if (files.length === 0) { + this.target.clear(); + this.hasPreview = false; + return; + } + void loadPreview().then(async (output) => { + await nextTask(); + if (this.disposed || requestVersion !== this.requestVersion) return; + if (!Array.isArray(output.images) || output.images.length !== files.length) { + throw new Error( + `${this.label} preview returned ${String(output.images?.length ?? 0)} images for ${String(files.length)} files.` ); - throw error; } + this.target.publish(output); + this.hasPreview = true; + }).catch((error) => { + if (this.disposed || requestVersion !== this.requestVersion) return; + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Could not refresh ${this.label} preview: ${message}`, error); + if (!this.hasPreview) this.target.clear(); + }); + } + /** Clear native media and invalidate pending work owned by a removed node. */ + dispose() { + if (this.disposed) return; + this.disposed = true; + this.requestVersion += 1; + this.target.clear(); + } +}; +function nextTask() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +// web/src/nativePreviewLifecycle.ts +var listeners = /* @__PURE__ */ new Set(); +var observer = null; +function subscribeNativePreviewLifecycle(listener) { + listeners.add(listener); + ensureObserver(); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + observer?.disconnect(); + observer = null; } }; - app2.registerExtension(extension); } -function errorMessage2(error) { - return error instanceof Error && error.message ? error.message : String(error); +function ensureObserver() { + if (observer) return; + observer = new MutationObserver(() => { + for (const listener of listeners) listener(); + }); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["src"] + }); } -function configureMaskBatchNode(candidate, executionEvents, loadPreview, logger = console, app2) { - if (!isMaskBatchNode(candidate)) return; + +// web/src/orderedMediaPreviewAffordances.ts +var MEDIA_MOVE_EARLIER_ATTRIBUTE = "data-ss-media-move-earlier"; +var MEDIA_MOVE_LATER_ATTRIBUTE = "data-ss-media-move-later"; +var MEDIA_REMOVE_ATTRIBUTE = "data-ss-media-remove"; +var MEDIA_INDEX_ATTRIBUTE = "data-ss-media-index"; +var OrderedMediaPreviewAffordances = class { + constructor(options) { + this.options = options; + window.addEventListener("resize", this.requestRefresh, true); + document.addEventListener("click", this.requestRefresh, true); + document.addEventListener("keydown", this.requestRefresh, true); + document.addEventListener("pointermove", this.requestRefresh, true); + document.addEventListener("pointerup", this.requestRefresh, true); + document.addEventListener("wheel", this.requestRefresh, true); + this.refresh(); + } + options; + elements = []; + animationFrame = null; + remainingSyncFrames = 0; + /** Recheck native layout across several frames after Comfy rerenders output. */ + refresh() { + this.remainingSyncFrames = Math.max(this.remainingSyncFrames, 4); + this.scheduleSync(); + } + /** Remove every overlay control and global layout listener. */ + dispose() { + window.removeEventListener("resize", this.requestRefresh, true); + document.removeEventListener("click", this.requestRefresh, true); + document.removeEventListener("keydown", this.requestRefresh, true); + document.removeEventListener("pointermove", this.requestRefresh, true); + document.removeEventListener("pointerup", this.requestRefresh, true); + document.removeEventListener("wheel", this.requestRefresh, true); + if (this.animationFrame !== null) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + for (const element of this.elements) element.root.remove(); + this.elements = []; + } + requestRefresh = () => { + this.remainingSyncFrames = Math.max(this.remainingSyncFrames, 2); + this.scheduleSync(); + }; + scheduleSync() { + if (this.animationFrame !== null) return; + this.animationFrame = requestAnimationFrame(() => { + this.animationFrame = null; + this.sync(); + this.remainingSyncFrames -= 1; + if (this.remainingSyncFrames > 0) this.scheduleSync(); + }); + } + sync() { + const slots = this.options.getSlots(); + this.resizeElements(slots.length); + for (const [index, elements] of this.elements.entries()) { + const slot = slots[index]; + if (!slot || slot.width <= 0 || slot.height <= 0) { + elements.root.hidden = true; + continue; + } + elements.root.hidden = false; + const stripHeight = Math.max(18, Math.min(26, slot.height * 0.16)); + Object.assign(elements.root.style, { + left: `${String(slot.left)}px`, + top: `${String(slot.top)}px`, + width: `${String(slot.width)}px`, + height: `${String(stripHeight)}px` + }); + elements.root.dataset.ssMediaIndex = String(index); + elements.earlier.dataset.ssMediaIndex = String(index); + elements.later.dataset.ssMediaIndex = String(index); + elements.remove.dataset.ssMediaIndex = String(index); + setActionAvailability(elements.earlier, index > 0); + setActionAvailability( + elements.later, + index < this.elements.length - 1 + ); + elements.earlier.setAttribute( + "aria-label", + `Move ${this.options.itemLabel} ${String(index + 1)} earlier` + ); + elements.later.setAttribute( + "aria-label", + `Move ${this.options.itemLabel} ${String(index + 1)} later` + ); + elements.remove.setAttribute( + "aria-label", + `Remove ${this.options.itemLabel} ${String(index + 1)}` + ); + } + } + resizeElements(count) { + while (this.elements.length > count) this.elements.pop()?.root.remove(); + while (this.elements.length < count) { + this.elements.push(this.createElements()); + } + } + createElements() { + const root = document.createElement("div"); + root.className = "ss-native-preview-affordance"; + Object.assign(root.style, { + position: "fixed", + zIndex: "9990", + display: "flex", + alignItems: "stretch", + pointerEvents: "none", + overflow: "hidden", + borderRadius: "4px 4px 0 0", + background: "rgba(20, 20, 20, 0.72)", + boxShadow: "inset 0 -1px 0 rgba(255, 255, 255, 0.16)" + }); + const earlier = controlButton("pi-arrow-left", "Move earlier"); + earlier.setAttribute(MEDIA_MOVE_EARLIER_ATTRIBUTE, "true"); + const later = controlButton("pi-arrow-right", "Move later"); + later.setAttribute(MEDIA_MOVE_LATER_ATTRIBUTE, "true"); + const remove = controlButton("pi-times", "Remove"); + remove.setAttribute(MEDIA_REMOVE_ATTRIBUTE, "true"); + bindAction(earlier, (index) => { + this.options.moveEarlier(index); + }); + bindAction(later, (index) => { + this.options.moveLater(index); + }); + bindAction(remove, (index) => { + this.options.remove(index); + }); + root.append(earlier, later, remove); + document.body.append(root); + return { root, earlier, later, remove }; + } +}; +function mediaIndex(element) { + const value = element.getAttribute(MEDIA_INDEX_ATTRIBUTE); + if (value === null) return null; + const index = Number(value); + return Number.isInteger(index) && index >= 0 ? index : null; +} +function controlButton(iconClass, title) { + const button = document.createElement("button"); + button.type = "button"; + button.title = title; + const icon = document.createElement("i"); + icon.className = `pi ${iconClass}`; + icon.setAttribute("aria-hidden", "true"); + button.append(icon); + Object.assign(button.style, { + appearance: "none", + border: "0", + padding: "0", + margin: "0", + minWidth: "0", + flex: "1 1 0", + color: "rgba(255, 255, 255, 0.92)", + background: "transparent", + fontSize: "13px", + cursor: "pointer", + pointerEvents: "auto" + }); + return button; +} +function bindAction(button, action) { + button.addEventListener("pointerdown", stopControlPointerEvent); + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const index = mediaIndex(button); + if (index !== null) action(index); + }); +} +function setActionAvailability(button, available) { + button.disabled = !available; + button.style.cursor = available ? "pointer" : "default"; + button.style.opacity = available ? "1" : "0.35"; +} +function stopControlPointerEvent(event) { + event.preventDefault(); + event.stopPropagation(); +} + +// web/src/orderedMediaPreviewTransaction.ts +var OrderedMediaPreviewTransaction = class { + constructor(options) { + this.options = options; + } + options; + surface = null; + items = null; + handoffFrame = null; + /** Move an already-loaded native item without rebuilding its gallery. */ + move(index, destination) { + if (!this.begin()) return false; + const items = this.items; + if (!items || index < 0 || index >= items.length || destination < 0 || destination >= items.length || index === destination) { + return false; + } + const [moved] = items.splice(index, 1); + if (!moved) return false; + items.splice(destination, 0, moved); + this.apply(); + return true; + } + /** Remove an already-loaded item and let the native gallery compact itself. */ + remove(index) { + if (!this.begin()) return false; + const items = this.items; + if (!items || index < 0 || index >= items.length) return false; + items.splice(index, 1); + this.apply(); + return true; + } + /** Return live native-cell geometry while an optimistic transaction is active. */ + activeSlots() { + if (!this.surface || !this.items) return null; + return this.surface.slots.slice(0, this.items.length).map((slot) => slot()); + } + /** End the transaction after Comfy's authoritative native preview is ready. */ + authoritativePublished() { + if (!this.surface || this.handoffFrame !== null) return; + this.scheduleHandoff(); + } + /** Release native surface changes and stop any pending handoff check. */ + dispose() { + if (this.handoffFrame !== null) cancelAnimationFrame(this.handoffFrame); + this.handoffFrame = null; + this.finish(); + } + begin() { + if (this.surface && this.items) return true; + const surface = this.options.captureSurface(); + if (!surface || surface.items.length === 0) return false; + this.surface = surface; + this.items = [...surface.items]; + return true; + } + apply() { + if (!this.surface || !this.items) return; + this.surface.apply(this.items); + this.options.stateChanged(); + } + scheduleHandoff() { + this.handoffFrame = requestAnimationFrame(() => { + this.handoffFrame = null; + if (!this.surface) return; + if (this.options.authoritativeReady()) { + this.finish(); + this.options.stateChanged(); + return; + } + this.scheduleHandoff(); + }); + } + finish() { + this.surface?.release(); + this.surface = null; + this.items = null; + } +}; + +// web/src/comfyImageReference.ts +function comfyImageReferenceKey(reference) { + return `${reference.type} +${reference.subfolder} +${reference.filename}`; +} +function comfyImageSourceKey(sourceUrl) { + try { + const url = new URL(sourceUrl, window.location.href); + return `${url.searchParams.get("type") ?? ""} +${url.searchParams.get("subfolder") ?? ""} +${url.searchParams.get("filename") ?? ""}`; + } catch { + return ""; + } +} +function loadedImagesMatchReferences(images, references) { + return images?.length === references.length && images.every((image, index) => { + const reference = references[index]; + return reference !== void 0 && comfyImageSourceKey(image.currentSrc || image.src) === comfyImageReferenceKey(reference); + }); +} + +// web/src/orderedMediaPreviewActions.ts +var OrderedMediaPreviewActions = class { + constructor(options) { + this.options = options; + this.transaction = new OrderedMediaPreviewTransaction({ + captureSurface: () => this.captureSurface(), + authoritativeReady: () => this.authoritativeReady(), + stateChanged: () => { + this.affordances.refresh(); + } + }); + this.affordances = new OrderedMediaPreviewAffordances({ + itemLabel: options.itemLabel, + getSlots: () => this.nativeSlots(), + moveEarlier: (index) => { + this.transaction.move(index, index - 1); + options.moveEarlier(index); + }, + moveLater: (index) => { + this.transaction.move(index, index + 1); + options.moveLater(index); + }, + remove: (index) => { + this.transaction.remove(index); + options.remove(index); + } + }); + this.unsubscribePreview = options.preview.subscribe(() => { + this.affordances.refresh(); + this.transaction.authoritativePublished(); + }); + this.unsubscribeLifecycle = subscribeNativePreviewLifecycle(() => { + this.affordances.refresh(); + }); + } + options; + affordances; + transaction; + unsubscribePreview; + unsubscribeLifecycle; + /** Remove layout listeners and every loader-owned overlay control. */ + dispose() { + this.unsubscribePreview(); + this.unsubscribeLifecycle(); + this.transaction.dispose(); + this.affordances.dispose(); + } + itemCount() { + const files = this.options.getFiles(); + const images = this.currentImages(); + return images?.length === files.length ? files.length : 0; + } + currentImages() { + const nodeId = this.nodeId(); + return nodeId ? this.options.app.nodeOutputs?.[nodeId]?.images : void 0; + } + domImages() { + const root = this.domRoot(); + const references = this.currentImages(); + if (!root || !references || references.length !== this.itemCount()) return []; + const remainingKeys = references.map(comfyImageReferenceKey); + const matched = []; + for (const image of Array.from( + root.querySelectorAll("img") + )) { + const key = comfyImageSourceKey(image.src); + const referenceIndex = remainingKeys.indexOf(key); + if (referenceIndex < 0) continue; + matched.push(image); + remainingKeys.splice(referenceIndex, 1); + } + return remainingKeys.length === 0 ? matched : []; + } + domRoot() { + const nodeId = this.nodeId(); + if (!nodeId) return null; + return Array.from(document.querySelectorAll("[data-node-id]")).find( + (element) => element.dataset.nodeId === nodeId + ) ?? null; + } + canvasSlots() { + const canvasApi = this.options.app.canvas; + const nodePosition = this.options.node.pos; + const imageRects = this.options.node.imageRects; + if (!canvasApi || !nodePosition || !imageRects) return []; + const canvasRect = canvasApi.canvas.getBoundingClientRect(); + return imageRects.map(([x, y, width, height]) => { + const topLeft = canvasApi.convertOffsetToCanvas([ + nodePosition[0] + x, + nodePosition[1] + y + ]); + const bottomRight = canvasApi.convertOffsetToCanvas([ + nodePosition[0] + x + width, + nodePosition[1] + y + height + ]); + return { + left: canvasRect.left + topLeft[0], + top: canvasRect.top + topLeft[1], + width: bottomRight[0] - topLeft[0], + height: bottomRight[1] - topLeft[1] + }; + }); + } + nativeSlots() { + if (this.options.node.flags?.collapsed || this.options.node.imageIndex != null) { + return []; + } + const activeSlots = this.transaction.activeSlots(); + if (activeSlots) return activeSlots; + if (!this.isGridVisible()) return []; + const domSlots = this.domImages().map((image) => { + const rect = image.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height + }; + }); + return domSlots.length === this.itemCount() ? domSlots : this.canvasSlots(); + } + captureSurface() { + const domImages = this.domImages(); + if (domImages.length > 0) return this.domSurface(domImages); + const canvasImages = this.options.node.imgs; + if (!canvasImages || canvasImages.length !== this.currentImages()?.length) { + return null; + } + return { + items: previewItems(canvasImages), + slots: canvasImages.map((_, index) => () => this.canvasSlots()[index]), + apply: (items) => { + this.options.node.imgs = items.map((item) => item.image); + delete this.options.node.imageRects; + this.options.node.graph?.setDirtyCanvas?.(true, true); + }, + release: () => void 0 + }; + } + domSurface(images) { + const slots = images.map((image) => imageSlot(image)); + const targets = images.map((image) => image.closest("button") ?? image); + const originalDisplays = targets.map((target) => target.style.display); + return { + items: previewItems(images), + slots, + apply: (items) => { + for (const [index, image] of images.entries()) { + const item = items[index]; + const target = targets[index]; + if (!target) continue; + if (!item) { + target.style.display = "none"; + continue; + } + target.style.display = originalDisplays[index] ?? ""; + if (image.src !== item.sourceUrl) image.src = item.sourceUrl; + } + }, + release: () => { + for (const [index, target] of targets.entries()) { + target.style.display = originalDisplays[index] ?? ""; + } + } + }; + } + authoritativeReady() { + const references = this.currentImages(); + if (!references || references.length !== this.options.getFiles().length) { + return false; + } + const expectedKeys = references.map(comfyImageReferenceKey); + const rendered = this.authoritativeImages(expectedKeys); + return rendered.length === expectedKeys.length && rendered.every( + (image, index) => comfyImageSourceKey(image.src) === expectedKeys[index] && image.complete && image.naturalWidth > 0 + ); + } + authoritativeImages(expectedKeys) { + const root = this.domRoot(); + const domImages = root ? Array.from(root.querySelectorAll("img")) : []; + if (domImages.length === expectedKeys.length) return domImages; + return this.options.node.imgs ?? []; + } + isGridVisible() { + return !this.options.node.flags?.collapsed && this.options.node.imageIndex == null && this.itemCount() > 1; + } + nodeId() { + const nodeId = this.options.node.id; + return nodeId === void 0 ? void 0 : String(nodeId); + } +}; +function previewItems(images) { + return images.map((image) => ({ + sourceUrl: image.currentSrc || image.src, + image + })); +} +function imageSlot(image) { + return () => { + const rect = image.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height + }; + }; +} + +// web/src/orderedMediaSelection.ts +var OrderedMediaSelection = class { + files; + constructor(value = []) { + this.files = normalizeMediaFiles(value); + } + /** Return a defensive snapshot in authored order. */ + snapshot() { + return [...this.files]; + } + /** Replace every position with a normalized persisted widget value. */ + replace(value) { + this.files = normalizeMediaFiles(value); + return this.snapshot(); + } + /** Append every incoming position without deduplicating filenames. */ + append(value) { + this.files.push(...normalizeMediaFiles(value)); + return this.snapshot(); + } + /** Remove one exact position when it exists. */ + remove(index) { + if (validIndex(index, this.files.length)) this.files.splice(index, 1); + return this.snapshot(); + } + /** Move one exact position and retain all duplicate filenames. */ + move(from, to) { + if (!validIndex(from, this.files.length) || !validIndex(to, this.files.length)) { + return this.snapshot(); + } + const [moved] = this.files.splice(from, 1); + if (moved !== void 0) this.files.splice(to, 0, moved); + return this.snapshot(); + } +}; +function normalizeMediaFiles(value) { + const values = Array.isArray(value) ? value : [value]; + return values.filter( + (item) => typeof item === "string" && item.length > 0 + ); +} +function validIndex(index, length) { + return Number.isInteger(index) && index >= 0 && index < length; +} + +// web/src/orderedMediaNode.ts +function configureOrderedMediaNode(candidate, app2, api, config, logger = console) { + if (!isOrderedMediaNode(candidate, config.nodeId)) return; const imageWidget = findWidget(candidate, "image"); - const channelWidget = findWidget(candidate, "channel"); const uploadWidget = findNativeUploadWidget(candidate); - if (!imageWidget || !channelWidget || !uploadWidget?.callback) return; + if (!imageWidget || !uploadWidget?.callback) return; hideInternalWidget(imageWidget); hideInternalWidget(uploadWidget); - const preview = new MaskBatchPreviewController( - executionEvents, - candidate, - loadPreview, - logger, - () => { - clearNativePreview(candidate, app2); - } + const selection = new OrderedMediaSelection(imageWidget.value); + const nativePreview = new NativeNodePreview(app2, api, candidate); + const preview = new OrderedMediaPreviewController( + nativePreview, + `${config.labels.singular} loader`, + logger ); + let programmaticSelectionUpdate = false; let selectionIntent = "replace"; let appendBase = []; let uploadPending = false; - let programmaticSelectionUpdate = false; let ignoredUploadCallbackFiles; const nativeUploadCallback = uploadWidget.callback; - const replaceWidget = candidate.addWidget( - "button", - "simple_syrup_replace_masks", - "image", - () => { - selectionIntent = "replace"; - appendBase = []; - uploadPending = true; - nativeUploadCallback.call(uploadWidget); - }, - nativeButtonOptions( - "Choose one or more masks and replace the current ordered list." - ) - ); - replaceWidget.label = REPLACE_MASKS_LABEL; - const setSelectedMasks = (files) => { + const setPersistedFiles = (files) => { programmaticSelectionUpdate = true; try { imageWidget.value = [...files]; @@ -671,155 +1233,145 @@ function configureMaskBatchNode(candidate, executionEvents, loadPreview, logger programmaticSelectionUpdate = false; } }; - uploadWidget.callback = (value) => { + const refresh = (files) => { + config.onSelectionChanged?.([...files]); + preview.refresh(files, () => config.preview([...files], candidate)); + }; + const commit = (current, previous) => { + setPersistedFiles(current); + candidate.onWidgetChanged?.( + imageWidget.name, + [...current], + [...previous], + imageWidget + ); + candidate.graph?.setDirtyCanvas?.(true, true); + refresh(current); + }; + const removeAt = (index) => { + const previous = selection.snapshot(); + if (index < 0 || index >= previous.length) return; + const current = selection.remove(index); + commit(current, previous); + }; + const moveTo = (index, destination) => { + const previous = selection.snapshot(); + if (index < 0 || index >= previous.length || destination < 0 || destination >= previous.length || index === destination) { + return; + } + const current = selection.move(index, destination); + commit(current, previous); + }; + const previewActions = new OrderedMediaPreviewActions({ + app: app2, + node: candidate, + preview: nativePreview, + itemLabel: config.labels.singular, + getFiles: () => selection.snapshot(), + moveEarlier: (index) => { + moveTo(index, index - 1); + }, + moveLater: (index) => { + moveTo(index, index + 1); + }, + remove: removeAt + }); + const beginReplace = () => { selectionIntent = "replace"; appendBase = []; uploadPending = true; + }; + const beginAppend = () => { + selectionIntent = "append"; + appendBase = selection.snapshot(); + uploadPending = true; + }; + nativeButton(candidate, "simple_syrup_replace_media", config.labels.replace, () => { + beginReplace(); + nativeUploadCallback.call(uploadWidget); + }); + nativeButton(candidate, "simple_syrup_add_media", config.labels.add, () => { + beginAppend(); + nativeUploadCallback.call(uploadWidget); + }); + uploadWidget.callback = (value) => { + beginReplace(); nativeUploadCallback.call(uploadWidget, value); }; - const addWidget = candidate.addWidget( - "button", - "simple_syrup_add_masks", - "image", - () => { - selectionIntent = "append"; - appendBase = selectedMaskFiles(imageWidget); - uploadPending = true; - nativeUploadCallback.call(uploadWidget); - }, - nativeButtonOptions( - "Upload one or more masks and append them after the current ordered list." - ) - ); - addWidget.label = ADD_MASKS_LABEL; - const selectedMaskWidget = candidate.addWidget( - "combo", - "simple_syrup_selected_mask", - EMPTY_MASK_SELECTION_LABEL, - (value) => { - const selectedIndex = selectedMaskIndex(selectedMaskWidget, value); - candidate.imageIndex = selectedIndex; - candidate.graph?.setDirtyCanvas?.(true, true); - }, - { - serialize: false, - tooltip: "Select one loaded mask by its ordered position for preview or removal.", - values: [] - } - ); - selectedMaskWidget.label = "selected mask"; - const removeWidget = candidate.addWidget( - "button", - "simple_syrup_remove_mask", - "image", - () => { - const previous = selectedMaskFiles(imageWidget); - const index = activeMaskIndex(candidate, selectedMaskWidget, previous); - if (index === null) return; - const current = previous.toSpliced(index, 1); - setSelectedMasks(current); - updateMaskSelection(selectedMaskWidget, current, index); - candidate.imageIndex = current.length === 0 ? null : Math.min(index, current.length - 1); - notifySelectionChanged(candidate, imageWidget, { current, previous }); - updateRemoveAvailability(removeWidget, current.length); - preview.refresh(current, selectedChannel(channelWidget)); - }, - nativeButtonOptions( - "Open a mask in the preview gallery, then remove that position from the loaded list." - ) - ); - removeWidget.label = REMOVE_MASK_LABEL; imageWidget.callback = (value) => { if (programmaticSelectionUpdate) return; const callbackValue = value ?? imageWidget.value; if (uploadPending && !Array.isArray(callbackValue)) return; - const incoming = normalizeMaskFiles(callbackValue); - if (ignoredUploadCallbackFiles && sameMaskFiles(incoming, ignoredUploadCallbackFiles)) { + const incoming = normalizeMediaFiles(callbackValue); + if (ignoredUploadCallbackFiles && sameMediaFiles(incoming, ignoredUploadCallbackFiles)) { ignoredUploadCallbackFiles = void 0; return; } ignoredUploadCallbackFiles = uploadPending ? [...incoming] : void 0; uploadPending = false; - const current = selectionIntent === "append" ? [...appendBase, ...incoming] : incoming; - const appended = selectionIntent === "append"; + const current = selectionIntent === "append" ? selection.replace([...appendBase, ...incoming]) : selection.replace(incoming); selectionIntent = "replace"; appendBase = []; - if (appended) setSelectedMasks(current); - candidate.imageIndex = null; - updateMaskSelection(selectedMaskWidget, current); - updateRemoveAvailability(removeWidget, current.length); - preview.refresh(current, selectedChannel(channelWidget)); - }; - const originalChannelCallback = channelWidget.callback; - channelWidget.callback = (value) => { - originalChannelCallback?.call(channelWidget, value); - const files = selectedMaskFiles(imageWidget); - if (files.length > 0) { - preview.refresh(files, selectedChannel(channelWidget, value)); + if (!sameMediaFiles(current, normalizeMediaFiles(imageWidget.value))) { + setPersistedFiles(current); } + candidate.graph?.setDirtyCanvas?.(true, true); + refresh(current); }; - resetIntentForExternalUploads(candidate, () => { - selectionIntent = "replace"; - appendBase = []; - uploadPending = false; - }); + for (const widgetName of config.previewWidgetNames ?? []) { + const widget = findWidget(candidate, widgetName); + if (!widget) continue; + const originalCallback = widget.callback; + widget.callback = (value) => { + originalCallback?.call(widget, value); + if (value !== void 0) widget.value = value; + refresh(selection.snapshot()); + }; + } + const restoreExternalUploads = wrapExternalAppendUploads(candidate, beginAppend); const originalOnGraphConfigured = candidate.onGraphConfigured; candidate.onGraphConfigured = function(...args) { const result = originalOnGraphConfigured?.apply(this, args); - const restored = selectedMaskFiles(imageWidget); - if (!Array.isArray(imageWidget.value)) setSelectedMasks(restored); - updateMaskSelection(selectedMaskWidget, restored); - updateRemoveAvailability(removeWidget, restored.length); - if (restored.length > 0) { - preview.refresh(restored, selectedChannel(channelWidget)); - } + const restored = selection.replace(imageWidget.value); + if (!Array.isArray(imageWidget.value)) setPersistedFiles(restored); + refresh(restored); return result; }; - const initial = selectedMaskFiles(imageWidget); - if (!Array.isArray(imageWidget.value)) setSelectedMasks(initial); - updateMaskSelection(selectedMaskWidget, initial); - updateRemoveAvailability(removeWidget, initial.length); - if (initial.length > 0) { - preview.refresh(initial, selectedChannel(channelWidget)); - } + const originalOnRemoved = candidate.onRemoved; + candidate.onRemoved = function(...args) { + restoreExternalUploads(); + previewActions.dispose(); + preview.dispose(); + return originalOnRemoved?.apply(this, args); + }; + const initial = selection.snapshot(); + if (!Array.isArray(imageWidget.value)) setPersistedFiles(initial); + refresh(initial); } -function hideInternalWidget(widget) { - widget.options ??= {}; - widget.options.hidden = true; - widget.hidden = true; - widget.computeSize = () => [0, -4]; +function registerOrderedMediaNode(app2, api, extensionName, config, logger = console) { + app2.registerExtension({ + name: extensionName, + nodeCreated(candidate) { + try { + configureOrderedMediaNode(candidate, app2, api, config, logger); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + `Could not configure ${config.labels.singular} loader: ${message}`, + error + ); + throw error; + } + } + }); } -function updateMaskSelection(widget, files, preferredIndex = 0) { - const values = files.map( - (file, index) => `${String(index + 1)}. ${file}` - ); - const hasMasks = values.length > 0; - widget.options ??= {}; - widget.options.values = hasMasks ? values : [EMPTY_MASK_SELECTION_LABEL]; - widget.disabled = !hasMasks; - widget.options.disabled = !hasMasks; - widget.value = hasMasks ? values[Math.min(Math.max(preferredIndex, 0), values.length - 1)] : EMPTY_MASK_SELECTION_LABEL; -} -function selectedMaskIndex(widget, callbackValue) { - if (widget.disabled) return null; - const value = callbackValue ?? widget.value; - const values = widget.options?.values ?? []; - const index = typeof value === "string" ? values.indexOf(value) : -1; - return index >= 0 ? index : null; -} -function clearNativePreview(node, app2) { - node.imgs = void 0; - node.images = []; - if (node.id !== void 0) delete app2?.nodeOutputs?.[String(node.id)]; - node.graph?.setDirtyCanvas?.(true, true); -} -function activeMaskIndex(node, selectedMaskWidget, files) { - const galleryIndex = node.imageIndex; - if (galleryIndex !== null && galleryIndex !== void 0 && galleryIndex >= 0 && galleryIndex < files.length) { - return galleryIndex; - } - const selectedIndex = selectedMaskIndex(selectedMaskWidget); - return selectedIndex !== null && selectedIndex < files.length ? selectedIndex : null; +function nativeButton(node, name, label, callback) { + const widget = node.addWidget("button", name, "ordered_media", callback, { + serialize: false, + tooltip: label + }); + widget.label = label; + return widget; } function findWidget(node, name) { return node.widgets?.find((widget) => widget.name === name); @@ -829,55 +1381,29 @@ function findNativeUploadWidget(node) { (widget) => widget.type === "button" && widget.value === "image" && widget.options?.serialize === false && widget.options.canvasOnly === true ); } -function nativeButtonOptions(tooltip) { - return { serialize: false, tooltip }; +function hideInternalWidget(widget) { + widget.options ??= {}; + widget.options.hidden = true; + widget.hidden = true; + widget.computeSize = () => [0, -4]; } -function selectedChannel(channelWidget, callbackValue) { - const value = callbackValue ?? channelWidget.value; - return typeof value === "string" && value.length > 0 ? value : void 0; -} -function selectedMaskFiles(widget) { - return normalizeMaskFiles(widget.value); -} -function normalizeMaskFiles(value) { - const values = Array.isArray(value) ? value : [value]; - return values.filter( - (item) => typeof item === "string" && item.length > 0 - ); -} -function sameMaskFiles(left, right) { +function sameMediaFiles(left, right) { return left.length === right.length && left.every((file, index) => file === right[index]); } -function updateRemoveAvailability(widget, count) { - const disabled = count === 0; - widget.disabled = disabled; - widget.options ??= {}; - widget.options.disabled = disabled; -} -function notifySelectionChanged(node, imageWidget, change) { - node.onWidgetChanged?.( - imageWidget.name, - [...change.current], - [...change.previous], - imageWidget - ); - node.graph?.setDirtyCanvas?.(true, true); -} -function resetIntentForExternalUploads(node, reset) { +function wrapExternalAppendUploads(node, beginAppend) { const originalPasteFiles = node.pasteFiles; const originalOnDragDrop = node.onDragDrop; - const originalOnRemoved = node.onRemoved; const wrappedPasteFiles = originalPasteFiles ? (...args) => { - reset(); + beginAppend(); return originalPasteFiles.apply(node, args); } : void 0; const wrappedOnDragDrop = originalOnDragDrop ? (...args) => { - reset(); + beginAppend(); return originalOnDragDrop.apply(node, args); } : void 0; if (wrappedPasteFiles) node.pasteFiles = wrappedPasteFiles; if (wrappedOnDragDrop) node.onDragDrop = wrappedOnDragDrop; - node.onRemoved = function(...args) { + return () => { if (node.pasteFiles === wrappedPasteFiles) { if (originalPasteFiles) node.pasteFiles = originalPasteFiles; else delete node.pasteFiles; @@ -886,13 +1412,84 @@ function resetIntentForExternalUploads(node, reset) { if (originalOnDragDrop) node.onDragDrop = originalOnDragDrop; else delete node.onDragDrop; } - return originalOnRemoved?.apply(this, args); }; } -function isMaskBatchNode(candidate) { +function isOrderedMediaNode(candidate, nodeId) { if (typeof candidate !== "object" || candidate === null) return false; const node = candidate; - return node.constructor?.comfyClass === LOAD_MASK_BATCH_NODE_ID && typeof node.addWidget === "function"; + return node.constructor?.comfyClass === nodeId && typeof node.addWidget === "function"; +} + +// web/src/maskBatchUpload.ts +var LOAD_MASK_BATCH_NODE_ID = "SimpleSyrup.LoadMaskBatch"; +function registerMaskBatchUpload(app2, api, loadPreview = getMaskBatchPreview, logger = console) { + registerOrderedMediaNode( + app2, + api, + "SimpleSyrup.LoadMaskBatchUpload", + maskConfig(loadPreview), + logger + ); +} +function maskConfig(loadPreview) { + return { + nodeId: LOAD_MASK_BATCH_NODE_ID, + labels: { + singular: "mask", + plural: "masks", + replace: "Replace masks...", + add: "Add masks..." + }, + previewWidgetNames: ["channel"], + preview: (files, node) => loadPreview(files, selectedChannel(node)) + }; +} +function selectedChannel(node) { + const value = node.widgets?.find((widget) => widget.name === "channel")?.value; + return typeof value === "string" && value.length > 0 ? value : "alpha"; +} + +// web/src/comfyImageUrl.ts +function comfyImageUrl(reference, apiURL = (path) => path) { + const query = new URLSearchParams({ + filename: reference.filename, + subfolder: reference.subfolder, + type: reference.type + }); + return apiURL(`/view?${query.toString()}`); +} +function inputImageReference(path) { + const annotated = path.trim().replace(/\s+\[input\]$/, ""); + const normalized = annotated.replaceAll("\\", "/"); + const separator = normalized.lastIndexOf("/"); + return { + filename: normalized.slice(separator + 1), + subfolder: separator >= 0 ? normalized.slice(0, separator) : "", + type: "input" + }; +} + +// web/src/imageListUpload.ts +function registerImageListUpload(app2, api, logger = console) { + registerOrderedMediaNode( + app2, + api, + "SimpleSyrup.LoadImageListUpload", + { + nodeId: "SimpleSyrup.LoadImageList", + labels: { + singular: "image", + plural: "images", + replace: "Replace images...", + add: "Add images..." + }, + preview: (files) => Promise.resolve({ + images: files.map(inputImageReference), + animated: files.map(() => false) + }) + }, + logger + ); } // web/src/interactiveInspector.ts @@ -949,6 +1546,26 @@ var InteractiveInspectorController = class { } }; +// web/src/domWidgetLayout.ts +var FixedDomWidgetLayout = class { + constructor(node, widget, measure) { + this.node = node; + Object.defineProperty(widget, "computeLayoutSize", { + configurable: true, + value: void 0, + writable: true + }); + widget.computeSize = measure; + } + node; + /** Recompute legacy widget allocation after the content mode changes. */ + reflow() { + if (!this.node.graph) return; + this.node.arrange?.(); + this.node.graph.setDirtyCanvas?.(true, true); + } +}; + // web/src/imageViewport.ts var ImageViewport = class { constructor(source, display) { @@ -1054,6 +1671,476 @@ function parseColor(value) { ]; } +// web/src/selectionModel.ts +var SelectionModel = class { + hovered = null; + selected = null; + candidates = []; + subscribers = /* @__PURE__ */ new Set(); + /** Subscribe to selection changes and receive the current state immediately. */ + subscribe(subscriber) { + this.subscribers.add(subscriber); + subscriber(this.state()); + return () => this.subscribers.delete(subscriber); + } + /** Replace the hover target and all regions currently beneath the pointer. */ + hover(candidates) { + this.candidates = [...candidates]; + this.hovered = candidates[0] ?? null; + this.publish(); + } + /** Clear transient pointer state without changing the pinned region. */ + clearHover() { + this.candidates = []; + this.hovered = null; + this.publish(); + } + /** Pin one region, or clear the pinned selection with null. */ + select(id) { + this.selected = id; + this.publish(); + } + /** Cycle through overlapping pointer candidates and pin the result. */ + selectNextCandidate() { + if (this.candidates.length === 0) return this.selected; + const currentIndex = this.selected === null ? -1 : this.candidates.indexOf(this.selected); + const next = this.candidates[(currentIndex + 1) % this.candidates.length] ?? null; + this.select(next); + return next; + } + /** Return immutable selection state for rendering or tests. */ + state() { + return { + hovered: this.hovered, + selected: this.selected, + candidates: [...this.candidates], + active: this.hovered ?? this.selected + }; + } + publish() { + const state = this.state(); + for (const subscriber of this.subscribers) subscriber(state); + } +}; + +// web/src/segPreviewInspector.ts +var SegPreviewInspector = class { + constructor(inspectRegion = () => void 0, changeMode = () => void 0, apiURL = (path) => path, loadImage = loadPreviewImage) { + this.inspectRegion = inspectRegion; + this.changeMode = changeMode; + this.apiURL = apiURL; + this.loadImage = loadImage; + installStyles(); + this.element.className = "ss-segs-preview"; + this.body.className = "ss-segs-preview__body"; + this.status.className = "ss-segs-preview__status"; + this.status.setAttribute("aria-live", "polite"); + const toolbar = document.createElement("nav"); + toolbar.className = "ss-segs-preview__toolbar"; + toolbar.setAttribute("aria-label", "SEGS preview mode"); + toolbar.append(this.overlayButton, this.gridButton); + this.overlayButton.addEventListener("click", () => { + this.setMode("overlay"); + }); + this.gridButton.addEventListener("click", () => { + this.setMode("grid"); + }); + this.element.append(toolbar, this.body, this.status); + this.updateMode(); + this.showMessage("Run the workflow to inspect SEGS."); + } + inspectRegion; + changeMode; + apiURL; + loadImage; + element = document.createElement("section"); + overlayButton = modeButton("Overlay"); + gridButton = modeButton("Grid"); + body = document.createElement("div"); + status = document.createElement("div"); + mode = "overlay"; + committed; + unsubscribeSelection; + highlightCanvas; + /** Return the renderer height needed by the active preview surface. */ + preferredHeight() { + return this.mode === "overlay" ? 360 : 38; + } + /** Select the overlay or native gallery and optionally notify its owner. */ + setMode(mode, notifyOwner = true) { + if (mode === this.mode) { + if (notifyOwner) this.changeMode(mode); + return; + } + this.mode = mode; + this.updateMode(); + this.renderMode(); + if (notifyOwner) this.changeMode(mode); + } + /** Show a loading state while execution assets are decoded. */ + setLoading() { + this.showMessage("Loading SEGS preview\u2026"); + } + /** Load source and atlas assets without publishing partial state. */ + async prepare(document2) { + const [image, atlasImage] = await Promise.all([ + this.loadImage(comfyImageUrl(document2.preview.image, this.apiURL)), + this.loadImage(comfyImageUrl(document2.atlas.image, this.apiURL)) + ]); + const atlas = maskAtlasFromImage(document2, atlasImage); + let disposed = false; + return { + commit: () => { + if (!disposed) this.commit(document2, image, atlas); + }, + dispose: () => { + disposed = true; + } + }; + } + /** Show an actionable error without breaking the node lifecycle. */ + showError(message) { + this.showMessage(message, true); + } + /** Release selection subscriptions and rendered state. */ + dispose() { + this.unsubscribeSelection?.(); + this.unsubscribeSelection = void 0; + this.committed = void 0; + this.highlightCanvas = void 0; + this.body.replaceChildren(); + this.status.textContent = ""; + } + commit(document2, image, atlas) { + this.unsubscribeSelection?.(); + const selection = new SelectionModel(); + const masks = new Map( + document2.regions.map((region) => [ + region.id, + maskCanvas(atlas, region) + ]) + ); + this.committed = { + document: document2, + image, + atlas, + viewport: new ImageViewport(document2.source, document2.preview), + masks, + selection + }; + this.unsubscribeSelection = selection.subscribe((state) => { + this.renderSelection(state); + }); + this.renderMode(); + } + renderMode() { + const committed = this.committed; + if (!committed) return; + if (this.mode === "overlay") { + this.renderOverlay(); + return; + } + this.highlightCanvas = void 0; + this.body.replaceChildren(); + this.status.textContent = `${String(committed.document.regions.length)} regions`; + } + renderOverlay() { + const committed = required(this.committed); + const stack = document.createElement("div"); + stack.className = "ss-segs-preview__canvas-stack"; + const base = sizedCanvas(committed.document.preview); + const highlight = sizedCanvas(committed.document.preview); + highlight.className = "ss-segs-preview__highlight"; + this.highlightCanvas = highlight; + const context = context2d(base); + context.drawImage( + committed.image, + 0, + 0, + committed.document.preview.width, + committed.document.preview.height + ); + context.globalAlpha = 0.38; + for (const region of committed.document.regions) { + drawRegionMask(context, committed, region); + } + context.globalAlpha = 1; + highlight.addEventListener("pointermove", (event) => { + const point = committed.viewport.sourcePoint( + event.clientX, + event.clientY, + highlight.getBoundingClientRect() + ); + const hits = committed.atlas.hitsAt(point.x, point.y); + committed.selection.hover(hits.map((region) => region.id)); + }); + highlight.addEventListener("pointerleave", () => { + committed.selection.clearHover(); + }); + highlight.addEventListener("click", () => { + const selected = committed.selection.selectNextCandidate(); + const region = committed.document.regions.find( + (candidate) => candidate.id === selected + ); + if (region) { + this.setMode("grid", false); + this.inspectRegion(region.index); + } + committed.selection.select(null); + }); + stack.append(base, highlight); + this.body.replaceChildren(stack); + this.renderSelection(committed.selection.state()); + } + renderSelection(state) { + const committed = this.committed; + const highlight = this.highlightCanvas; + if (!committed || !highlight) return; + const active = state.active ? committed.document.regions.find((region) => region.id === state.active) : void 0; + const context = context2d(highlight); + context.clearRect(0, 0, highlight.width, highlight.height); + if (active) { + context.save(); + context.globalAlpha = 0.9; + context.shadowColor = "rgba(255, 255, 255, 0.95)"; + context.shadowBlur = 8; + drawRegionMask(context, committed, active); + context.restore(); + } + if (!active) { + this.status.textContent = `${String(committed.document.regions.length)} regions`; + } else if (state.candidates.length > 1) { + this.status.textContent = `${regionTitle(active)} \xB7 ${String(state.candidates.length)} overlapping regions \xB7 click to inspect`; + } else { + this.status.textContent = `${regionTitle(active)} \xB7 click to inspect`; + } + } + showMessage(message, error = false) { + const content = document.createElement("div"); + content.className = "ss-segs-preview__message"; + content.dataset.error = String(error); + content.textContent = message; + this.body.replaceChildren(content); + this.status.textContent = ""; + } + updateMode() { + this.element.dataset.mode = this.mode; + this.overlayButton.setAttribute( + "aria-pressed", + String(this.mode === "overlay") + ); + this.gridButton.setAttribute("aria-pressed", String(this.mode === "grid")); + } +}; +function modeButton(label) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + return button; +} +function drawRegionMask(context, committed, region) { + const placement = committed.viewport.displayRectangle(region.crop); + const mask = committed.masks.get(region.id); + if (!mask) return; + context.drawImage( + mask, + placement.x, + placement.y, + placement.width, + placement.height + ); +} +function maskCanvas(atlas, region) { + const canvas = sizedCanvas(region.atlas); + context2d(canvas).putImageData(atlas.coloredMask(region), 0, 0); + return canvas; +} +function sizedCanvas(size) { + const canvas = document.createElement("canvas"); + canvas.width = size.width; + canvas.height = size.height; + return canvas; +} +function context2d(canvas) { + const context = canvas.getContext("2d"); + if (!context) throw new Error("Simple Preview SEGS requires canvas rendering."); + return context; +} +function regionTitle(region) { + const label = region.label.trim(); + return `${String(region.index + 1)}. ${label || "region"}`; +} +function required(value) { + if (!value) throw new Error("Simple Preview SEGS has no committed document."); + return value; +} +function loadPreviewImage(url) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => { + resolve(image); + }, { once: true }); + image.addEventListener( + "error", + () => { + reject(new Error("Simple Preview SEGS could not load a preview asset.")); + }, + { once: true } + ); + image.src = url; + }); +} +var stylesInstalled = false; +function installStyles() { + if (stylesInstalled) return; + const style = document.createElement("style"); + style.dataset.simpleSyrupSegsPreview = "true"; + style.textContent = ` + .ss-segs-preview { box-sizing: border-box; width: 100%; color: var(--fg-color, #ddd); font: 12px sans-serif; } + .ss-segs-preview * { box-sizing: border-box; } + .ss-segs-preview__toolbar { display: flex; gap: 4px; margin: 0 0 6px; } + .ss-segs-preview__toolbar button { flex: 1; min-height: 26px; border: 1px solid var(--border-color, #555); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; } + .ss-segs-preview__toolbar button[aria-pressed="true"] { border-color: var(--p-primary-color, #6aa9ff); background: color-mix(in srgb, var(--p-primary-color, #6aa9ff) 28%, var(--comfy-input-bg, #222)); } + .ss-segs-preview__body { overflow: hidden; border: 1px solid var(--border-color, #444); border-radius: 6px; background: var(--comfy-menu-bg, #181818); } + .ss-segs-preview[data-mode="grid"] .ss-segs-preview__body { display: none; } + .ss-segs-preview[data-mode="grid"] .ss-segs-preview__status { display: none; } + .ss-segs-preview__canvas-stack { position: relative; line-height: 0; background: #111; } + .ss-segs-preview__canvas-stack canvas { display: block; width: 100%; height: auto; } + .ss-segs-preview__highlight { position: absolute; inset: 0; cursor: crosshair; } + .ss-segs-preview__status { min-height: 22px; padding: 5px 2px 0; color: var(--descrip-text, #aaa); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .ss-segs-preview__message { display: grid; min-height: 160px; place-items: center; padding: 18px; color: var(--descrip-text, #aaa); text-align: center; } + .ss-segs-preview__message[data-error="true"] { color: var(--error-text, #ff8a80); } + `; + document.head.append(style); + stylesInstalled = true; +} + +// web/src/nativePreviewNavigator.ts +var NativePreviewNavigator = class { + constructor(app2, node) { + this.app = app2; + this.node = node; + } + app; + node; + pendingReference; + legacyInspectionFrame; + unsubscribeLifecycle; + /** Report whether Comfy currently owns a DOM-backed preview surface. */ + usesDomPreview() { + return this.domRoot() !== null; + } + /** Inspect one zero-based native preview item in either node renderer. */ + inspect(index) { + if (!Number.isInteger(index) || index < 0) return; + if (this.domRoot()) { + const images = this.images(); + if (!images || index >= images.length) return; + const reference = images[index]; + if (this.clickDomImage(reference)) return; + this.pendingReference = reference; + this.unsubscribeLifecycle ??= subscribeNativePreviewLifecycle(() => { + this.completePendingInspection(); + }); + return; + } + this.scheduleLegacyImage(index); + } + /** Wait for Comfy to restore canvas images before selecting native detail. */ + scheduleLegacyImage(index) { + this.cancelLegacyInspection(); + const inspectWhenReady = () => { + const images = this.node.imgs; + if (images !== void 0 && images.length > index) { + this.legacyInspectionFrame = void 0; + this.openLegacyImage(index); + return; + } + this.legacyInspectionFrame = requestAnimationFrame(inspectWhenReady); + }; + this.legacyInspectionFrame = requestAnimationFrame(inspectWhenReady); + } + /** Open one loaded image through Comfy's canvas detail state. */ + openLegacyImage(index) { + this.node.imageIndex = index; + const position = this.app.canvas?.graph_mouse; + if (position) { + this.node.pointerDown = { index, pos: [position[0], position[1]] }; + } + delete this.node.imageRects; + this.node.graph?.setDirtyCanvas?.(true, true); + } + /** Return the native preview to its one authoritative gallery. */ + showGrid() { + this.cancelLegacyInspection(); + this.pendingReference = void 0; + if (this.clickDomGrid()) return; + this.node.imageIndex = null; + delete this.node.imageRects; + this.node.graph?.setDirtyCanvas?.(true, true); + } + /** Cancel pending navigation when native images are hidden. */ + hide() { + this.cancelLegacyInspection(); + this.pendingReference = void 0; + this.node.imageIndex = null; + delete this.node.imageRects; + this.node.graph?.setDirtyCanvas?.(true, true); + } + /** Release native-preview lifecycle observation. */ + dispose() { + this.cancelLegacyInspection(); + this.pendingReference = void 0; + this.unsubscribeLifecycle?.(); + this.unsubscribeLifecycle = void 0; + } + cancelLegacyInspection() { + if (this.legacyInspectionFrame === void 0) return; + cancelAnimationFrame(this.legacyInspectionFrame); + this.legacyInspectionFrame = void 0; + } + completePendingInspection() { + const reference = this.pendingReference; + if (!reference || !this.clickDomImage(reference)) return; + this.pendingReference = void 0; + } + clickDomImage(reference) { + const root = this.domRoot(); + if (!root) return false; + const image = Array.from(root.querySelectorAll("img")).find( + (candidate) => comfyImageSourceKey(candidate.src) === comfyImageReferenceKey(reference) + ); + const button = image?.closest("button"); + if (!button) return false; + button.click(); + return true; + } + clickDomGrid() { + const root = this.domRoot(); + if (!root) return false; + const button = Array.from(root.querySelectorAll("button")).find( + (candidate) => candidate.getAttribute("aria-label") === "Grid view" || candidate.title === "Grid view" + ); + if (!button) return false; + button.click(); + return true; + } + domRoot() { + const nodeId = this.nodeId(); + if (!nodeId) return null; + return Array.from(document.querySelectorAll("[data-node-id]")).find( + (element) => element.dataset.nodeId === nodeId + ) ?? null; + } + images() { + const nodeId = this.nodeId(); + return nodeId ? this.app.nodeOutputs?.[nodeId]?.images : void 0; + } + nodeId() { + return this.node.id === void 0 ? void 0 : String(this.node.id); + } +}; + // web/src/segPreviewTypes.ts var SEG_PREVIEW_OUTPUT_KEY = "simple_syrup_segs_preview"; function parseSegPreviewDocument(output) { @@ -1073,14 +2160,6 @@ function parseSegPreviewDocument(output) { const regions = candidate.regions.map(parseRegion); return { version: 1, source, preview, atlas, regions }; } -function previewAssetUrl(reference, apiURL = (path) => path) { - const query = new URLSearchParams({ - filename: reference.filename, - subfolder: reference.subfolder, - type: reference.type - }); - return apiURL(`/view?${query.toString()}`); -} function parseRegion(value, index) { if (!isRecord(value)) { throw new Error( @@ -1173,436 +2252,147 @@ function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -// web/src/selectionModel.ts -var SelectionModel = class { - hovered = null; - selected = null; - candidates = []; - subscribers = /* @__PURE__ */ new Set(); - /** Subscribe to selection changes and receive the current state immediately. */ - subscribe(subscriber) { - this.subscribers.add(subscriber); - subscriber(this.state()); - return () => this.subscribers.delete(subscriber); +// web/src/segPreviewNativeSurface.ts +var SegPreviewNativeSurface = class { + constructor(app2, api, node) { + this.app = app2; + this.node = node; + this.preview = new NativeNodePreview(app2, api, node); + this.navigator = new NativePreviewNavigator(app2, node); } - /** Replace the hover target and all regions currently beneath the pointer. */ - hover(candidates) { - this.candidates = [...candidates]; - this.hovered = candidates[0] ?? null; - this.publish(); - } - /** Clear transient pointer state without changing the pinned region. */ - clearHover() { - this.candidates = []; - this.hovered = null; - this.publish(); - } - /** Pin one region, or clear the pinned selection with null. */ - select(id) { - this.selected = id; - this.publish(); - } - /** Cycle through overlapping pointer candidates and pin the result. */ - selectNextCandidate() { - if (this.candidates.length === 0) return this.selected; - const currentIndex = this.selected === null ? -1 : this.candidates.indexOf(this.selected); - const next = this.candidates[(currentIndex + 1) % this.candidates.length] ?? null; - this.select(next); - return next; - } - /** Return immutable selection state for rendering or tests. */ - state() { - return { - hovered: this.hovered, - selected: this.selected, - candidates: [...this.candidates], - active: this.hovered ?? this.selected - }; - } - publish() { - const state = this.state(); - for (const subscriber of this.subscribers) subscriber(state); - } -}; - -// web/src/segPreviewInspector.ts -var SegPreviewInspector = class { - constructor(apiURL = (path) => path, loadImage = loadPreviewImage) { - this.apiURL = apiURL; - this.loadImage = loadImage; - installStyles(); - this.element.className = "ss-segs-preview"; - this.body.className = "ss-segs-preview__body"; - this.status.className = "ss-segs-preview__status"; - this.status.setAttribute("aria-live", "polite"); - const toolbar = document.createElement("nav"); - toolbar.className = "ss-segs-preview__toolbar"; - toolbar.setAttribute("aria-label", "SEGS preview mode"); - toolbar.append(this.overlayButton, this.gridButton); - this.overlayButton.addEventListener("click", () => { - this.setMode("overlay"); - }); - this.gridButton.addEventListener("click", () => { - this.setMode("grid"); - }); - this.element.append(toolbar, this.body, this.status); - this.updateModeButtons(); - this.showMessage("Run the workflow to inspect SEGS."); - } - apiURL; - loadImage; - element = document.createElement("section"); - body = document.createElement("div"); - status = document.createElement("div"); - overlayButton = modeButton("Overlay"); - gridButton = modeButton("Grid"); + app; + node; + preview; + navigator; + authoritativeOutput; mode = "overlay"; - committed; - unsubscribeSelection; - highlightCanvas; - focusContainer; - gridButtons = /* @__PURE__ */ new Map(); - /** Show a native loading state while execution assets are decoded. */ - setLoading() { - this.showMessage("Loading SEGS preview\u2026"); + /** Accept a complete backend result and apply the active surface mode. */ + update(value) { + const output = executionOutput(value); + if (!output) return; + const document2 = parseSegPreviewDocument(value); + if (!document2) return; + if (output.images.length !== document2.regions.length) return; + this.authoritativeOutput = output; + if (this.mode === "overlay") { + this.hideNativePreview(); + } else if (this.mode === "grid") { + this.navigator.showGrid(); + } } - /** Load source and atlas assets without publishing partial state. */ - async prepare(document2) { - const [image, atlasImage] = await Promise.all([ - this.loadImage(previewAssetUrl(document2.preview.image, this.apiURL)), - this.loadImage(previewAssetUrl(document2.atlas.image, this.apiURL)) - ]); - const atlas = maskAtlasFromImage(document2, atlasImage); - let disposed = false; - return { - commit: () => { - if (!disposed) this.commit(document2, image, atlas); - }, - dispose: () => { - disposed = true; - } - }; + /** Show only the custom semantic overlay. */ + showOverlay() { + this.mode = "overlay"; + this.hideNativePreview(); } - /** Replace the inspector with an actionable failure message. */ - showError(message) { - this.showMessage(message, true); + /** Show Comfy's native gallery and return it from native detail mode. */ + showGrid() { + this.mode = "grid"; + this.setCanvasPreviewVisible(true); + if (this.nativeImagesNeedPublishing()) { + this.publishAuthoritativeOutput(); + } + this.navigator.showGrid(); } - /** Release node-owned DOM and interaction subscriptions. */ + /** Show one SEG through Comfy's native detail viewer. */ + inspect(index) { + const output = this.authoritativeOutput; + if (!output || index < 0 || index >= output.images.length) return; + this.mode = "detail"; + this.setCanvasPreviewVisible(true); + if (this.nativeImagesNeedPublishing()) { + this.publishAuthoritativeOutput(); + } + this.navigator.inspect(index); + } + /** Release native-preview observers owned by this node. */ dispose() { - this.unsubscribeSelection?.(); - this.unsubscribeSelection = void 0; - this.committed = void 0; - this.body.replaceChildren(); - this.status.textContent = ""; + this.navigator.dispose(); } - commit(document2, image, atlas) { - this.unsubscribeSelection?.(); - const selection = new SelectionModel(); - const masks = new Map( - document2.regions.map((region) => [ - region.id, - maskCanvas(atlas, region) - ]) - ); - this.committed = { - document: document2, - image, - atlas, - viewport: new ImageViewport(document2.source, document2.preview), - masks, - selection - }; - this.unsubscribeSelection = selection.subscribe((state) => { - this.renderSelection(state); + hideNativePreview() { + const usesDomPreview = this.navigator.usesDomPreview(); + this.navigator.hide(); + this.setCanvasPreviewVisible(false); + if (!usesDomPreview) return; + this.node.imgs = []; + const output = this.authoritativeOutput; + if (!output || output.images.length === 0) return; + this.preview.publish({ + ...output, + images: [], + animated: [] }); - this.renderMode(); } - setMode(mode) { - if (this.mode === mode) return; - this.mode = mode; - this.updateModeButtons(); - this.renderMode(); - } - updateModeButtons() { - this.overlayButton.setAttribute( - "aria-pressed", - String(this.mode === "overlay") + /** Hide the persistent Nodes 1.0 preview widget outside native modes. */ + setCanvasPreviewVisible(visible) { + const widget = this.node.widgets?.find( + (candidate) => candidate.name === "$$canvas-image-preview" ); - this.gridButton.setAttribute("aria-pressed", String(this.mode === "grid")); + if (!widget) return; + widget.hidden = !visible; + widget.options ??= {}; + widget.options.hidden = !visible; } - renderMode() { - this.highlightCanvas = void 0; - this.focusContainer = void 0; - this.gridButtons.clear(); - if (!this.committed) return; - this.body.replaceChildren( - this.mode === "overlay" ? this.overlayView() : this.gridView() - ); - this.renderSelection(this.committed.selection.state()); - } - overlayView() { - const committed = required(this.committed); - const container = document.createElement("div"); - container.className = "ss-segs-preview__overlay-view"; - const stack = document.createElement("div"); - stack.className = "ss-segs-preview__canvas-stack"; - const base = sizedCanvas(committed.document.preview); - const highlight = sizedCanvas(committed.document.preview); - highlight.className = "ss-segs-preview__highlight"; - this.highlightCanvas = highlight; - const context = context2d(base); - context.drawImage( - committed.image, - 0, - 0, - committed.document.preview.width, - committed.document.preview.height - ); - context.globalAlpha = 0.38; - for (const region of committed.document.regions) { - drawRegionMask(context, committed, region); - } - context.globalAlpha = 1; - highlight.addEventListener("pointermove", (event) => { - const point = committed.viewport.sourcePoint( - event.clientX, - event.clientY, - highlight.getBoundingClientRect() - ); - const hits = committed.atlas.hitsAt(point.x, point.y); - committed.selection.hover(hits.map((region) => region.id)); + publishAuthoritativeOutput() { + const output = this.authoritativeOutput; + if (!output) return; + delete this.node.images; + delete this.node.imgs; + this.preview.publish({ + ...output, + images: [...output.images] }); - highlight.addEventListener("pointerleave", () => { - committed.selection.clearHover(); - }); - highlight.addEventListener("click", () => { - committed.selection.selectNextCandidate(); - }); - stack.append(base, highlight); - this.focusContainer = document.createElement("div"); - this.focusContainer.className = "ss-segs-preview__focus"; - container.append(stack, this.focusContainer); - return container; } - gridView() { - const committed = required(this.committed); - const grid = document.createElement("div"); - grid.className = "ss-segs-preview__grid"; - grid.setAttribute("role", "listbox"); - if (committed.document.regions.length === 0) { - const empty = document.createElement("div"); - empty.className = "ss-segs-preview__empty"; - empty.textContent = "No SEGS to display."; - grid.append(empty); - return grid; - } - for (const region of committed.document.regions) { - const card = document.createElement("button"); - card.type = "button"; - card.className = "ss-segs-preview__card"; - card.setAttribute("role", "option"); - card.dataset.regionId = region.id; - const canvas = regionPreviewCanvas(committed, region, 160, 132); - const label = document.createElement("span"); - label.textContent = regionTitle(region); - card.append(canvas, label); - card.addEventListener("pointerenter", () => { - committed.selection.hover([region.id]); - }); - card.addEventListener("pointerleave", () => { - committed.selection.clearHover(); - }); - card.addEventListener("click", () => { - committed.selection.select(region.id); - }); - this.gridButtons.set(region.id, card); - grid.append(card); - } - return grid; + /** Check whether Comfy's current output still contains every SEG image. */ + hasAuthoritativeImages() { + const output = this.authoritativeOutput; + if (!output || this.node.id === void 0) return false; + return this.app.nodeOutputs?.[String(this.node.id)]?.images?.length === output.images.length; } - renderSelection(state) { - const committed = this.committed; - if (!committed) return; - const active = state.active ? committed.document.regions.find((region) => region.id === state.active) : void 0; - if (this.highlightCanvas) { - const context = context2d(this.highlightCanvas); - context.clearRect( - 0, - 0, - this.highlightCanvas.width, - this.highlightCanvas.height - ); - if (active) { - context.save(); - context.globalAlpha = 0.9; - context.shadowColor = "rgba(255, 255, 255, 0.95)"; - context.shadowBlur = 8; - drawRegionMask(context, committed, active); - context.restore(); - } - } - for (const [id, button] of this.gridButtons) { - const selected = id === state.selected; - const highlighted = id === active?.id; - button.setAttribute("aria-selected", String(selected)); - button.dataset.highlighted = String(highlighted); - } - this.renderFocus(active); - if (!active) { - this.status.textContent = `${String(committed.document.regions.length)} regions`; - } else if (state.candidates.length > 1) { - this.status.textContent = `${regionTitle(active)} \xB7 ${String(state.candidates.length)} overlapping regions \xB7 click to cycle`; - } else { - this.status.textContent = regionTitle(active); - } - } - renderFocus(region) { - if (!this.focusContainer || !this.committed) return; - if (!region) { - this.focusContainer.replaceChildren(); - return; - } - const title = document.createElement("strong"); - title.textContent = regionTitle(region); - const details = document.createElement("span"); - details.textContent = `${String(Math.round(region.confidence * 100))}% confidence \xB7 ${region.area.toLocaleString()} px`; - this.focusContainer.replaceChildren( - regionPreviewCanvas(this.committed, region, 320, 220), - title, - details - ); - } - showMessage(message, error = false) { - const content = document.createElement("div"); - content.className = "ss-segs-preview__message"; - content.dataset.error = String(error); - content.textContent = message; - this.body.replaceChildren(content); - this.status.textContent = ""; + /** Decide whether the active renderer needs its native images republished. */ + nativeImagesNeedPublishing() { + const output = this.authoritativeOutput; + if (!output) return false; + if (this.navigator.usesDomPreview()) return !this.hasAuthoritativeImages(); + return !loadedImagesMatchReferences(this.node.imgs, output.images); } }; -function drawRegionMask(context, committed, region) { - const placement = committed.viewport.displayRectangle(region.crop); - const mask = committed.masks.get(region.id); - if (!mask) return; - context.drawImage( - mask, - placement.x, - placement.y, - placement.width, - placement.height - ); +function executionOutput(value) { + if (typeof value !== "object" || value === null) return void 0; + const output = value; + if (!Array.isArray(output.images) || !output.images.every(isImageResult)) { + return void 0; + } + return output; } -function regionPreviewCanvas(committed, region, maximumWidth, maximumHeight) { - const aspect = region.crop.width / region.crop.height; - const width = Math.max(80, Math.min(maximumWidth, Math.round(maximumHeight * aspect))); - const height = Math.max(64, Math.min(maximumHeight, Math.round(width / aspect))); - const canvas = sizedCanvas({ width, height }); - const context = context2d(canvas); - const source = committed.viewport.displayRectangle(region.crop); - context.drawImage( - committed.image, - source.x, - source.y, - source.width, - source.height, - 0, - 0, - width, - height - ); - context.globalAlpha = 0.45; - const mask = committed.masks.get(region.id); - if (mask) context.drawImage(mask, 0, 0, width, height); - context.globalAlpha = 1; - return canvas; -} -function maskCanvas(atlas, region) { - const canvas = sizedCanvas(region.atlas); - context2d(canvas).putImageData(atlas.coloredMask(region), 0, 0); - return canvas; -} -function sizedCanvas(size) { - const canvas = document.createElement("canvas"); - canvas.width = size.width; - canvas.height = size.height; - return canvas; -} -function context2d(canvas) { - const context = canvas.getContext("2d"); - if (!context) throw new Error("Simple Preview SEGS requires canvas rendering."); - return context; -} -function regionTitle(region) { - const label = region.label.trim(); - return `${String(region.index + 1)}. ${label || "region"}`; -} -function modeButton(label) { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = label; - return button; -} -function required(value) { - if (!value) throw new Error("Simple Preview SEGS has no committed document."); - return value; -} -function loadPreviewImage(url) { - return new Promise((resolve, reject) => { - const image = new Image(); - image.addEventListener("load", () => { - resolve(image); - }, { once: true }); - image.addEventListener( - "error", - () => { - reject(new Error("Simple Preview SEGS could not load a preview asset.")); - }, - { once: true } - ); - image.src = url; - }); -} -var stylesInstalled = false; -function installStyles() { - if (stylesInstalled) return; - const style = document.createElement("style"); - style.dataset.simpleSyrupSegsPreview = "true"; - style.textContent = ` - .ss-segs-preview { box-sizing: border-box; width: 100%; min-height: 360px; color: var(--fg-color, #ddd); font: 12px sans-serif; } - .ss-segs-preview * { box-sizing: border-box; } - .ss-segs-preview__toolbar { display: flex; gap: 4px; margin: 0 0 6px; } - .ss-segs-preview__toolbar button { flex: 1; min-height: 26px; border: 1px solid var(--border-color, #555); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; } - .ss-segs-preview__toolbar button[aria-pressed="true"] { border-color: var(--p-primary-color, #6aa9ff); background: color-mix(in srgb, var(--p-primary-color, #6aa9ff) 28%, var(--comfy-input-bg, #222)); } - .ss-segs-preview__body { min-height: 320px; overflow: hidden; border: 1px solid var(--border-color, #444); border-radius: 6px; background: var(--comfy-menu-bg, #181818); } - .ss-segs-preview__canvas-stack { position: relative; line-height: 0; background: #111; } - .ss-segs-preview__canvas-stack canvas { display: block; width: 100%; height: auto; } - .ss-segs-preview__highlight { position: absolute; inset: 0; cursor: crosshair; } - .ss-segs-preview__status { min-height: 22px; padding: 5px 2px 0; color: var(--descrip-text, #aaa); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .ss-segs-preview__focus { display: grid; grid-template-columns: minmax(96px, 42%) 1fr; gap: 3px 9px; align-items: start; padding: 7px; border-top: 1px solid var(--border-color, #444); } - .ss-segs-preview__focus canvas { grid-row: 1 / span 2; width: 100%; height: auto; border-radius: 4px; background: #111; } - .ss-segs-preview__focus span { color: var(--descrip-text, #aaa); } - .ss-segs-preview__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 6px; max-height: 430px; padding: 7px; overflow: auto; } - .ss-segs-preview__card { min-width: 0; padding: 4px; border: 1px solid var(--border-color, #444); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; text-align: left; } - .ss-segs-preview__card[data-highlighted="true"], .ss-segs-preview__card[aria-selected="true"] { border-color: var(--p-primary-color, #6aa9ff); box-shadow: 0 0 0 1px var(--p-primary-color, #6aa9ff); } - .ss-segs-preview__card canvas { display: block; width: 100%; height: 94px; object-fit: contain; margin-bottom: 4px; background: #111; } - .ss-segs-preview__card span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .ss-segs-preview__message, .ss-segs-preview__empty { display: grid; min-height: 320px; place-items: center; padding: 18px; color: var(--descrip-text, #aaa); text-align: center; } - .ss-segs-preview__message[data-error="true"] { color: var(--error-text, #ff8a80); } - `; - document.head.append(style); - stylesInstalled = true; +function isImageResult(value) { + if (typeof value !== "object" || value === null) return false; + const image = value; + return typeof image.filename === "string" && typeof image.subfolder === "string" && (image.type === "input" || image.type === "output" || image.type === "temp"); } // web/src/segPreviewNode.ts var SIMPLE_PREVIEW_SEGS_NODE_ID = "SimpleSyrup.SimplePreviewSEGS"; function registerSimplePreviewSEGS(app2, api, loadImage, logger = console) { const controllers = /* @__PURE__ */ new Map(); + const nativeSurfaces = /* @__PURE__ */ new Map(); + const widgetLayouts = /* @__PURE__ */ new Map(); const extension = { name: "SimpleSyrup.SimplePreviewSEGS", nodeCreated(candidate) { if (!isPreviewNode(candidate)) return; + const nativeSurface = new SegPreviewNativeSurface(app2, api, candidate); + const layoutOwner = {}; const inspector = new SegPreviewInspector( + (index) => { + nativeSurface.inspect(index); + layoutOwner.current?.reflow(); + }, + (mode) => { + if (mode === "overlay") nativeSurface.showOverlay(); + else nativeSurface.showGrid(); + layoutOwner.current?.reflow(); + }, (path) => api.apiURL?.(path) ?? path, loadImage ); @@ -1621,10 +2411,17 @@ function registerSimplePreviewSEGS(app2, api, loadImage, logger = console) { widget.options ??= {}; widget.options.serialize = false; widget.options.canvasOnly = false; - widget.computeSize = (width = 420) => [Math.max(360, width), 470]; + const widgetLayout = new FixedDomWidgetLayout(candidate, widget, (width = 420) => [ + Math.max(360, width), + inspector.preferredHeight() + ]); + layoutOwner.current = widgetLayout; const registerId = () => { if (candidate.id !== void 0) { - controllers.set(String(candidate.id), controller); + const nodeId = String(candidate.id); + controllers.set(nodeId, controller); + nativeSurfaces.set(nodeId, nativeSurface); + widgetLayouts.set(nodeId, widgetLayout); } }; registerId(); @@ -1632,20 +2429,31 @@ function registerSimplePreviewSEGS(app2, api, loadImage, logger = console) { candidate.onExecuted = function(output) { originalExecuted?.call(this, output); controller.update(output); + nativeSurface.update(output); + widgetLayout.reflow(); }; const originalGraphConfigured = candidate.onGraphConfigured; candidate.onGraphConfigured = function(...args) { const result = originalGraphConfigured?.apply(this, args); registerId(); if (candidate.id !== void 0) { - controller.update(app2.nodeOutputs?.[String(candidate.id)]); + const output = app2.nodeOutputs?.[String(candidate.id)]; + controller.update(output); + nativeSurface.update(output); + widgetLayout.reflow(); } return result; }; const originalRemoved = candidate.onRemoved; candidate.onRemoved = function(...args) { - if (candidate.id !== void 0) controllers.delete(String(candidate.id)); + if (candidate.id !== void 0) { + const nodeId = String(candidate.id); + controllers.delete(nodeId); + nativeSurfaces.delete(nodeId); + widgetLayouts.delete(nodeId); + } controller.dispose(); + nativeSurface.dispose(); return originalRemoved?.apply(this, args); }; const computed = candidate.computeSize?.(); @@ -1653,13 +2461,15 @@ function registerSimplePreviewSEGS(app2, api, loadImage, logger = console) { if (current && candidate.setSize) { candidate.setSize([ Math.max(420, current[0]), - Math.max(520, computed?.[1] ?? current[1]) + Math.max(420, computed?.[1] ?? current[1]) ]); } }, onNodeOutputsUpdated(outputs) { for (const [nodeId, output] of Object.entries(outputs)) { controllers.get(nodeId)?.update(output); + nativeSurfaces.get(nodeId)?.update(output); + widgetLayouts.get(nodeId)?.reflow(); } } }; @@ -1682,4 +2492,5 @@ comfyApp.registerExtension({ } }); registerMaskBatchUpload(comfyApp, comfyApi); +registerImageListUpload(comfyApp, comfyApi); registerSimplePreviewSEGS(comfyApp, comfyApi); diff --git a/web/src/comfyImageReference.ts b/web/src/comfyImageReference.ts new file mode 100644 index 0000000..68b00fb --- /dev/null +++ b/web/src/comfyImageReference.ts @@ -0,0 +1,40 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { ComfyImageResult } from "./types"; + +/** Build the stable identity Comfy uses for an image result. */ +export function comfyImageReferenceKey(reference: ComfyImageResult): string { + return `${reference.type}\n${reference.subfolder}\n${reference.filename}`; +} + +/** Read a Comfy image identity from its rendered URL. */ +export function comfyImageSourceKey(sourceUrl: string): string { + try { + const url = new URL(sourceUrl, window.location.href); + return `${url.searchParams.get("type") ?? ""}\n${ + url.searchParams.get("subfolder") ?? "" + }\n${url.searchParams.get("filename") ?? ""}`; + } catch { + return ""; + } +} + +/** Check that loaded native images exactly match an ordered Comfy result. */ +export function loadedImagesMatchReferences( + images: readonly HTMLImageElement[] | undefined, + references: readonly ComfyImageResult[] +): boolean { + return ( + images?.length === references.length && + images.every((image, index) => { + const reference = references[index]; + return ( + reference !== undefined && + comfyImageSourceKey(image.currentSrc || image.src) === + comfyImageReferenceKey(reference) + ); + }) + ); +} diff --git a/web/src/comfyImageUrl.ts b/web/src/comfyImageUrl.ts new file mode 100644 index 0000000..fbabf18 --- /dev/null +++ b/web/src/comfyImageUrl.ts @@ -0,0 +1,29 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { ComfyImageResult } from "./types"; + +/** Build a Comfy view URL without assuming origin-root hosting. */ +export function comfyImageUrl( + reference: ComfyImageResult, + apiURL: (path: string) => string = (path) => path +): string { + const query = new URLSearchParams({ + filename: reference.filename, + subfolder: reference.subfolder, + type: reference.type + }); + return apiURL(`/view?${query.toString()}`); +} +/** Convert one persisted input-folder path into Comfy's preview reference. */ +export function inputImageReference(path: string): ComfyImageResult { + const annotated = path.trim().replace(/\s+\[input\]$/, ""); + const normalized = annotated.replaceAll("\\", "/"); + const separator = normalized.lastIndexOf("/"); + return { + filename: normalized.slice(separator + 1), + subfolder: separator >= 0 ? normalized.slice(0, separator) : "", + type: "input" + }; +} diff --git a/web/src/domWidgetLayout.ts b/web/src/domWidgetLayout.ts new file mode 100644 index 0000000..665b919 --- /dev/null +++ b/web/src/domWidgetLayout.ts @@ -0,0 +1,45 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +interface FixedDomWidget { + computeSize?: (width?: number) => [number, number]; + computeLayoutSize?: + | ((node: unknown) => { + minHeight: number; + maxHeight?: number; + minWidth: number; + maxWidth?: number; + }) + | undefined; +} + +interface WidgetLayoutNode { + arrange?: () => void; + graph?: { + setDirtyCanvas?: (foreground: boolean, background: boolean) => void; + }; +} + +/** Keep a custom DOM widget content-sized in both Comfy node renderers. */ +export class FixedDomWidgetLayout { + constructor( + private readonly node: WidgetLayoutNode, + widget: FixedDomWidget, + measure: (width?: number) => [number, number] + ) { + Object.defineProperty(widget, "computeLayoutSize", { + configurable: true, + value: undefined, + writable: true + }); + widget.computeSize = measure; + } + + /** Recompute legacy widget allocation after the content mode changes. */ + reflow(): void { + if (!this.node.graph) return; + this.node.arrange?.(); + this.node.graph.setDirtyCanvas?.(true, true); + } +} diff --git a/web/src/imageListUpload.ts b/web/src/imageListUpload.ts new file mode 100644 index 0000000..26208ae --- /dev/null +++ b/web/src/imageListUpload.ts @@ -0,0 +1,35 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { inputImageReference } from "./comfyImageUrl"; +import { registerOrderedMediaNode } from "./orderedMediaNode"; +import type { ComfyApi, ComfyApp, Logger } from "./types"; + +/** Register Load Image List through the shared ordered-media interface. */ +export function registerImageListUpload( + app: ComfyApp, + api: ComfyApi, + logger: Logger = console +): void { + registerOrderedMediaNode( + app, + api, + "SimpleSyrup.LoadImageListUpload", + { + nodeId: "SimpleSyrup.LoadImageList", + labels: { + singular: "image", + plural: "images", + replace: "Replace images...", + add: "Add images..." + }, + preview: (files) => + Promise.resolve({ + images: files.map(inputImageReference), + animated: files.map(() => false) + }) + }, + logger + ); +} diff --git a/web/src/main.ts b/web/src/main.ts index 742dcc3..fb0000c 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -8,6 +8,7 @@ import { app } from "../../../scripts/app.js"; import { registerSimpleSyrupSettings } from "./settings"; import { registerExternalLLMRefreshHook } from "./refresh"; import { registerMaskBatchUpload } from "./maskBatchUpload"; +import { registerImageListUpload } from "./imageListUpload"; import { registerSimplePreviewSEGS } from "./segPreviewNode"; import type { ComfyApi, ComfyApp } from "./types"; @@ -28,4 +29,5 @@ comfyApp.registerExtension({ }); registerMaskBatchUpload(comfyApp, comfyApi); +registerImageListUpload(comfyApp, comfyApi); registerSimplePreviewSEGS(comfyApp, comfyApi); diff --git a/web/src/maskBatchPreview.ts b/web/src/maskBatchPreview.ts deleted file mode 100644 index 1b0383c..0000000 --- a/web/src/maskBatchPreview.ts +++ /dev/null @@ -1,73 +0,0 @@ -// SimpleSyrup - workflow-focused ComfyUI extensions for image generation -// Copyright (C) 2026 Artificial Sweetener and contributors -// SPDX-License-Identifier: AGPL-3.0-or-later - -import { getMaskBatchPreview } from "./api"; -import type { - ComfyExecutionEvents, - ComfyNodeExecutionOutput, - Logger -} from "./types"; - -const EMPTY_PREVIEW: ComfyNodeExecutionOutput = { - images: [], - animated: [] -}; -const PREVIEW_PROMPT_ID = "simple-syrup-mask-batch-preview"; - -export type MaskBatchPreviewClient = ( - files: string[], - channel: string -) => Promise; - -export interface MaskBatchPreviewNode { - id?: string | number; - graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; -} - -/** Coordinate asynchronous native previews without publishing stale results. */ -export class MaskBatchPreviewController { - private requestVersion = 0; - - constructor( - private readonly executionEvents: ComfyExecutionEvents, - private readonly node: MaskBatchPreviewNode, - private readonly loadPreview: MaskBatchPreviewClient = getMaskBatchPreview, - private readonly logger: Logger = console, - private readonly clearNativePreview: () => void = () => undefined - ) {} - - /** Replace the visible preview with the exact selected-channel mask output. */ - refresh(files: string[], channel: string | undefined): void { - if (this.node.id === undefined || channel === undefined) return; - - const requestVersion = ++this.requestVersion; - this.clearNativePreview(); - this.publish(EMPTY_PREVIEW); - if (files.length === 0) return; - void this.loadPreview([...files], channel) - .then((output) => { - if (requestVersion === this.requestVersion) this.publish(output); - }) - .catch((error: unknown) => { - if (requestVersion !== this.requestVersion) return; - this.logger.warn("Could not refresh Load Mask Batch preview.", error); - }); - } - - /** Publish an execution-shaped result through Comfy's native preview. */ - private publish(output: ComfyNodeExecutionOutput): void { - if (this.node.id === undefined) return; - this.executionEvents.dispatchEvent( - new CustomEvent("executed", { - detail: { - node: String(this.node.id), - output, - merge: false, - prompt_id: PREVIEW_PROMPT_ID - } - }) - ); - this.node.graph?.setDirtyCanvas?.(true, true); - } -} diff --git a/web/src/maskBatchUpload.ts b/web/src/maskBatchUpload.ts index 9fe4ec0..308fab3 100644 --- a/web/src/maskBatchUpload.ts +++ b/web/src/maskBatchUpload.ts @@ -2,485 +2,70 @@ // Copyright (C) 2026 Artificial Sweetener and contributors // SPDX-License-Identifier: AGPL-3.0-or-later +import { getMaskBatchPreview } from "./api"; import { - MaskBatchPreviewController, - type MaskBatchPreviewClient -} from "./maskBatchPreview"; -import type { - ComfyApp, - ComfyExecutionEvents, - ComfyExtension, - ComfyImageResult, - Logger -} from "./types"; + configureOrderedMediaNode, + registerOrderedMediaNode, + type OrderedMediaNode +} from "./orderedMediaNode"; +import type { ComfyApi, ComfyApp, Logger } from "./types"; const LOAD_MASK_BATCH_NODE_ID = "SimpleSyrup.LoadMaskBatch"; -const REPLACE_MASKS_LABEL = "Replace masks..."; -const ADD_MASKS_LABEL = "Add masks..."; -const REMOVE_MASK_LABEL = "Remove selected mask"; -const EMPTY_MASK_SELECTION_LABEL = "No masks loaded"; -type SelectionIntent = "append" | "replace"; -type NativeCallback = (...args: unknown[]) => unknown; +export type MaskBatchPreviewClient = typeof getMaskBatchPreview; -interface MaskBatchWidget { - name: string; - value: unknown; - type?: string; - label?: string; - callback?: (value?: unknown) => void; - computeSize?: (width?: number) => [number, number]; - hidden?: boolean; - disabled?: boolean; - options?: { - canvasOnly?: boolean; - disabled?: boolean; - serialize?: boolean; - tooltip?: string; - hidden?: boolean; - values?: string[]; - }; -} - -interface MaskBatchNode { - constructor: { comfyClass?: string }; - id?: string | number; - widgets?: MaskBatchWidget[]; - imageIndex?: number | null; - images?: ComfyImageResult[]; - imgs?: unknown[] | undefined; - pasteFiles?: NativeCallback; - onDragDrop?: NativeCallback; - onRemoved?: NativeCallback; - onGraphConfigured?: NativeCallback; - onExecuted?: NativeCallback; - onWidgetChanged?: ( - name: string, - value: unknown, - previousValue: unknown, - widget: MaskBatchWidget - ) => void; - graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; - addWidget( - type: "button" | "combo", - name: string, - value: string | undefined, - callback: (value?: unknown) => void, - options: MaskBatchWidget["options"] - ): MaskBatchWidget; -} - -/** Register native upload-list controls for the mask batch loader. */ +/** Register Load Mask Batch through the shared ordered-media interface. */ export function registerMaskBatchUpload( app: ComfyApp, - executionEvents: ComfyExecutionEvents, - loadPreview?: MaskBatchPreviewClient, + api: ComfyApi, + loadPreview: MaskBatchPreviewClient = getMaskBatchPreview, logger: Logger = console ): void { - const extension: ComfyExtension = { - name: "SimpleSyrup.LoadMaskBatchUpload", - nodeCreated(node: unknown) { - try { - configureMaskBatchNode(node, executionEvents, loadPreview, logger, app); - } catch (error: unknown) { - logger.warn( - `Could not configure Load Mask Batch native controls: ${errorMessage(error)}`, - error - ); - throw error; - } - } - }; - app.registerExtension(extension); + registerOrderedMediaNode( + app, + api, + "SimpleSyrup.LoadMaskBatchUpload", + maskConfig(loadPreview), + logger + ); } -/** Return a stable diagnostic message for an unknown frontend failure. */ -function errorMessage(error: unknown): string { - return error instanceof Error && error.message - ? error.message - : String(error); -} - -/** Wire native Comfy upload, append, remove, persistence, and preview behavior. */ +/** Configure one mask loader while retaining selected-channel rendering. */ export function configureMaskBatchNode( candidate: unknown, - executionEvents: ComfyExecutionEvents, - loadPreview?: MaskBatchPreviewClient, - logger: Logger = console, - app?: ComfyApp + app: ComfyApp, + api: ComfyApi, + loadPreview: MaskBatchPreviewClient = getMaskBatchPreview, + logger: Logger = console ): void { - if (!isMaskBatchNode(candidate)) return; - - const imageWidget = findWidget(candidate, "image"); - const channelWidget = findWidget(candidate, "channel"); - const uploadWidget = findNativeUploadWidget(candidate); - if (!imageWidget || !channelWidget || !uploadWidget?.callback) return; - - hideInternalWidget(imageWidget); - hideInternalWidget(uploadWidget); - - const preview = new MaskBatchPreviewController( - executionEvents, + configureOrderedMediaNode( candidate, - loadPreview, - logger, - () => { - clearNativePreview(candidate, app); - } - ); - let selectionIntent: SelectionIntent = "replace"; - let appendBase: string[] = []; - let uploadPending = false; - let programmaticSelectionUpdate = false; - let ignoredUploadCallbackFiles: string[] | undefined; - const nativeUploadCallback = uploadWidget.callback; - - const replaceWidget = candidate.addWidget( - "button", - "simple_syrup_replace_masks", - "image", - () => { - selectionIntent = "replace"; - appendBase = []; - uploadPending = true; - nativeUploadCallback.call(uploadWidget); - }, - nativeButtonOptions( - "Choose one or more masks and replace the current ordered list." - ) - ); - replaceWidget.label = REPLACE_MASKS_LABEL; - - const setSelectedMasks = (files: string[]): void => { - programmaticSelectionUpdate = true; - try { - imageWidget.value = [...files]; - } finally { - programmaticSelectionUpdate = false; - } - }; - - uploadWidget.callback = (value?: unknown) => { - selectionIntent = "replace"; - appendBase = []; - uploadPending = true; - nativeUploadCallback.call(uploadWidget, value); - }; - - const addWidget = candidate.addWidget( - "button", - "simple_syrup_add_masks", - "image", - () => { - selectionIntent = "append"; - appendBase = selectedMaskFiles(imageWidget); - uploadPending = true; - nativeUploadCallback.call(uploadWidget); - }, - nativeButtonOptions( - "Upload one or more masks and append them after the current ordered list." - ) - ); - addWidget.label = ADD_MASKS_LABEL; - - const selectedMaskWidget = candidate.addWidget( - "combo", - "simple_syrup_selected_mask", - EMPTY_MASK_SELECTION_LABEL, - (value?: unknown) => { - const selectedIndex = selectedMaskIndex(selectedMaskWidget, value); - candidate.imageIndex = selectedIndex; - candidate.graph?.setDirtyCanvas?.(true, true); - }, + app, + api, { - serialize: false, - tooltip: - "Select one loaded mask by its ordered position for preview or removal.", - values: [] - } - ); - selectedMaskWidget.label = "selected mask"; - - const removeWidget = candidate.addWidget( - "button", - "simple_syrup_remove_mask", - "image", - () => { - const previous = selectedMaskFiles(imageWidget); - const index = activeMaskIndex(candidate, selectedMaskWidget, previous); - if (index === null) return; - const current = previous.toSpliced(index, 1); - - setSelectedMasks(current); - updateMaskSelection(selectedMaskWidget, current, index); - candidate.imageIndex = current.length === 0 ? null : Math.min(index, current.length - 1); - notifySelectionChanged(candidate, imageWidget, { current, previous }); - updateRemoveAvailability(removeWidget, current.length); - preview.refresh(current, selectedChannel(channelWidget)); + ...maskConfig(loadPreview), + preview: (files, node) => loadPreview(files, selectedChannel(node)) }, - nativeButtonOptions( - "Open a mask in the preview gallery, then remove that position from the loaded list." - ) - ); - removeWidget.label = REMOVE_MASK_LABEL; - - imageWidget.callback = (value?: unknown) => { - if (programmaticSelectionUpdate) return; - const callbackValue = value ?? imageWidget.value; - if (uploadPending && !Array.isArray(callbackValue)) return; - const incoming = normalizeMaskFiles(callbackValue); - if ( - ignoredUploadCallbackFiles && - sameMaskFiles(incoming, ignoredUploadCallbackFiles) - ) { - ignoredUploadCallbackFiles = undefined; - return; - } - ignoredUploadCallbackFiles = uploadPending ? [...incoming] : undefined; - uploadPending = false; - - const current = - selectionIntent === "append" ? [...appendBase, ...incoming] : incoming; - const appended = selectionIntent === "append"; - selectionIntent = "replace"; - appendBase = []; - - if (appended) setSelectedMasks(current); - candidate.imageIndex = null; - updateMaskSelection(selectedMaskWidget, current); - updateRemoveAvailability(removeWidget, current.length); - preview.refresh(current, selectedChannel(channelWidget)); - }; - - const originalChannelCallback = channelWidget.callback; - channelWidget.callback = (value?: unknown) => { - originalChannelCallback?.call(channelWidget, value); - const files = selectedMaskFiles(imageWidget); - if (files.length > 0) { - preview.refresh(files, selectedChannel(channelWidget, value)); - } - }; - - resetIntentForExternalUploads(candidate, () => { - selectionIntent = "replace"; - appendBase = []; - uploadPending = false; - }); - - const originalOnGraphConfigured = candidate.onGraphConfigured; - candidate.onGraphConfigured = function (...args: unknown[]): unknown { - const result = originalOnGraphConfigured?.apply(this, args); - const restored = selectedMaskFiles(imageWidget); - if (!Array.isArray(imageWidget.value)) setSelectedMasks(restored); - updateMaskSelection(selectedMaskWidget, restored); - updateRemoveAvailability(removeWidget, restored.length); - if (restored.length > 0) { - preview.refresh(restored, selectedChannel(channelWidget)); - } - return result; - }; - - const initial = selectedMaskFiles(imageWidget); - if (!Array.isArray(imageWidget.value)) setSelectedMasks(initial); - updateMaskSelection(selectedMaskWidget, initial); - updateRemoveAvailability(removeWidget, initial.length); - if (initial.length > 0) { - preview.refresh(initial, selectedChannel(channelWidget)); - } -} - -/** Keep serialized helper widgets out of both native node renderers. */ -function hideInternalWidget(widget: MaskBatchWidget): void { - widget.options ??= {}; - widget.options.hidden = true; - widget.hidden = true; - widget.computeSize = () => [0, -4]; -} - -/** Replace the native selector choices with ordered, duplicate-safe labels. */ -function updateMaskSelection( - widget: MaskBatchWidget, - files: string[], - preferredIndex = 0 -): void { - const values = files.map( - (file, index) => `${String(index + 1)}. ${file}` - ); - const hasMasks = values.length > 0; - widget.options ??= {}; - widget.options.values = hasMasks ? values : [EMPTY_MASK_SELECTION_LABEL]; - widget.disabled = !hasMasks; - widget.options.disabled = !hasMasks; - widget.value = hasMasks - ? values[Math.min(Math.max(preferredIndex, 0), values.length - 1)] - : EMPTY_MASK_SELECTION_LABEL; -} - -/** Resolve the selected ordered position from the native list widget. */ -function selectedMaskIndex( - widget: MaskBatchWidget, - callbackValue?: unknown -): number | null { - if (widget.disabled) return null; - const value = callbackValue ?? widget.value; - const values = widget.options?.values ?? []; - const index = typeof value === "string" ? values.indexOf(value) : -1; - return index >= 0 ? index : null; -} - -/** Clear classic and Nodes 2.0 preview state before publishing new output. */ -function clearNativePreview(node: MaskBatchNode, app?: ComfyApp): void { - node.imgs = undefined; - node.images = []; - if (node.id !== undefined) delete app?.nodeOutputs?.[String(node.id)]; - node.graph?.setDirtyCanvas?.(true, true); -} - -/** Prefer Comfy's active gallery image, then the native list selection. */ -function activeMaskIndex( - node: MaskBatchNode, - selectedMaskWidget: MaskBatchWidget, - files: string[] -): number | null { - const galleryIndex = node.imageIndex; - if ( - galleryIndex !== null && - galleryIndex !== undefined && - galleryIndex >= 0 && - galleryIndex < files.length - ) { - return galleryIndex; - } - const selectedIndex = selectedMaskIndex(selectedMaskWidget); - return selectedIndex !== null && selectedIndex < files.length - ? selectedIndex - : null; -} - -/** Return one native widget by its stable input name. */ -function findWidget( - node: MaskBatchNode, - name: string -): MaskBatchWidget | undefined { - return node.widgets?.find((widget) => widget.name === name); -} - -/** Find Comfy's image upload button without depending on localized labels. */ -function findNativeUploadWidget( - node: MaskBatchNode -): MaskBatchWidget | undefined { - return node.widgets?.find( - (widget) => - widget.type === "button" && - widget.value === "image" && - widget.options?.serialize === false && - widget.options.canvasOnly === true + logger ); } -/** Return options for a native button rendered by classic and Nodes 2.0. */ -function nativeButtonOptions(tooltip: string): MaskBatchWidget["options"] { - return { serialize: false, tooltip }; -} - -/** Return the newly selected native channel when it is usable. */ -function selectedChannel( - channelWidget: MaskBatchWidget, - callbackValue?: unknown -): string | undefined { - const value = callbackValue ?? channelWidget.value; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -/** Return the native multi-value widget as an ordered file list. */ -function selectedMaskFiles(widget: MaskBatchWidget): string[] { - return normalizeMaskFiles(widget.value); -} - -/** Narrow native scalar or multi-value widget state to valid file paths. */ -function normalizeMaskFiles(value: unknown): string[] { - const values = Array.isArray(value) ? value : [value]; - return values.filter( - (item): item is string => typeof item === "string" && item.length > 0 - ); -} - -/** Compare ordered upload results without relying on reactive proxy identity. */ -function sameMaskFiles(left: string[], right: string[]): boolean { - return ( - left.length === right.length && - left.every((file, index) => file === right[index]) - ); -} - -/** Disable removal only when the ordered mask list is empty. */ -function updateRemoveAvailability(widget: MaskBatchWidget, count: number): void { - const disabled = count === 0; - widget.disabled = disabled; - widget.options ??= {}; - widget.options.disabled = disabled; -} - -/** Notify Comfy when a non-upload control mutates persisted widget state. */ -function notifySelectionChanged( - node: MaskBatchNode, - imageWidget: MaskBatchWidget, - change: { current: string[]; previous: string[] } -): void { - node.onWidgetChanged?.( - imageWidget.name, - [...change.current], - [...change.previous], - imageWidget - ); - node.graph?.setDirtyCanvas?.(true, true); -} - -/** Prevent a cancelled Add action from affecting later paste or drop uploads. */ -function resetIntentForExternalUploads( - node: MaskBatchNode, - reset: () => void -): void { - const originalPasteFiles = node.pasteFiles; - const originalOnDragDrop = node.onDragDrop; - const originalOnRemoved = node.onRemoved; - - const wrappedPasteFiles: NativeCallback | undefined = originalPasteFiles - ? (...args: unknown[]): unknown => { - reset(); - return originalPasteFiles.apply(node, args); - } - : undefined; - const wrappedOnDragDrop: NativeCallback | undefined = originalOnDragDrop - ? (...args: unknown[]): unknown => { - reset(); - return originalOnDragDrop.apply(node, args); - } - : undefined; - - if (wrappedPasteFiles) node.pasteFiles = wrappedPasteFiles; - if (wrappedOnDragDrop) node.onDragDrop = wrappedOnDragDrop; - node.onRemoved = function (...args: unknown[]): unknown { - if (node.pasteFiles === wrappedPasteFiles) { - if (originalPasteFiles) node.pasteFiles = originalPasteFiles; - else delete node.pasteFiles; - } - if (node.onDragDrop === wrappedOnDragDrop) { - if (originalOnDragDrop) node.onDragDrop = originalOnDragDrop; - else delete node.onDragDrop; - } - return originalOnRemoved?.apply(this, args); +function maskConfig(loadPreview: MaskBatchPreviewClient) { + return { + nodeId: LOAD_MASK_BATCH_NODE_ID, + labels: { + singular: "mask", + plural: "masks", + replace: "Replace masks...", + add: "Add masks..." + }, + previewWidgetNames: ["channel"], + preview: (files: string[], node: OrderedMediaNode) => + loadPreview(files, selectedChannel(node)) }; } -/** Return true when the candidate is the native-widget batch mask loader. */ -function isMaskBatchNode(candidate: unknown): candidate is MaskBatchNode { - if (typeof candidate !== "object" || candidate === null) return false; - const node = candidate as Partial; - return ( - node.constructor?.comfyClass === LOAD_MASK_BATCH_NODE_ID && - typeof node.addWidget === "function" - ); +function selectedChannel(node: OrderedMediaNode): string { + const value = node.widgets?.find((widget) => widget.name === "channel")?.value; + return typeof value === "string" && value.length > 0 ? value : "alpha"; } - -export type { MaskBatchPreviewClient } from "./maskBatchPreview"; diff --git a/web/src/maskedTextureRenderer.ts b/web/src/maskedTextureRenderer.ts new file mode 100644 index 0000000..67d8f0a --- /dev/null +++ b/web/src/maskedTextureRenderer.ts @@ -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 + +import type { Dimensions, Rectangle } from "./segPreviewTypes"; + +/** Render source-image texture through an alpha mask onto a transparent canvas. */ +export class MaskedTextureRenderer { + constructor(private readonly sourceImage: CanvasImageSource) {} + + /** Fit and render one source crop while making every non-mask pixel transparent. */ + render( + sourceCrop: Rectangle, + mask: CanvasImageSource, + maximum: Dimensions + ): HTMLCanvasElement { + const size = fitWithin(sourceCrop, maximum); + const canvas = document.createElement("canvas"); + canvas.width = size.width; + canvas.height = size.height; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Simple Preview SEGS requires canvas rendering."); + } + context.save(); + context.drawImage( + this.sourceImage, + sourceCrop.x, + sourceCrop.y, + sourceCrop.width, + sourceCrop.height, + 0, + 0, + size.width, + size.height + ); + context.globalCompositeOperation = "destination-in"; + context.drawImage(mask, 0, 0, size.width, size.height); + context.restore(); + return canvas; + } +} + +/** Fit dimensions inside a bounding box without upscaling unavailable texture. */ +function fitWithin(source: Dimensions, maximum: Dimensions): Dimensions { + const scale = Math.min( + 1, + maximum.width / source.width, + maximum.height / source.height + ); + return { + width: Math.max(1, Math.round(source.width * scale)), + height: Math.max(1, Math.round(source.height * scale)) + }; +} diff --git a/web/src/nativeNodePreview.ts b/web/src/nativeNodePreview.ts new file mode 100644 index 0000000..4ef271c --- /dev/null +++ b/web/src/nativeNodePreview.ts @@ -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 + +import type { + ComfyApi, + ComfyApp, + ComfyNodeExecutionOutput +} from "./types"; +import type { OrderedMediaPreviewTarget } from "./orderedMediaPreview"; + +interface PreviewNode { + readonly id?: string | number; +} + +/** Feed input-backed media through the same output path as Preview Image. */ +export class NativeNodePreview implements OrderedMediaPreviewTarget { + private readonly publishListeners = new Set<() => void>(); + + constructor( + private readonly app: ComfyApp, + private readonly api: ComfyApi, + private readonly node: PreviewNode + ) {} + + /** Publish media as an execution-shaped output for both node renderers. */ + publish(output: ComfyNodeExecutionOutput): void { + const nodeId = this.nodeId(); + if (!nodeId) return; + this.app.nodeOutputs ??= {}; + this.app.nodeOutputs[nodeId] = output; + this.api.dispatchEvent( + new CustomEvent("executed", { + detail: { node: nodeId, display_node: nodeId, output } + }) + ); + for (const listener of this.publishListeners) listener(); + } + + /** Notify renderer adapters after native output changes. */ + subscribe(listener: () => void): () => void { + this.publishListeners.add(listener); + return () => this.publishListeners.delete(listener); + } + + /** Remove loader-owned media from Comfy's native preview surface. */ + clear(): void { + this.publish({ images: [], animated: [] }); + } + + private nodeId(): string | undefined { + if (this.node.id === undefined) return undefined; + return String(this.node.id); + } +} diff --git a/web/src/nativePreviewLifecycle.ts b/web/src/nativePreviewLifecycle.ts new file mode 100644 index 0000000..c68387b --- /dev/null +++ b/web/src/nativePreviewLifecycle.ts @@ -0,0 +1,34 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +const listeners = new Set<() => void>(); +let observer: MutationObserver | null = null; + +/** Subscribe to native preview mounts and source replacements across renderers. */ +export function subscribeNativePreviewLifecycle( + listener: () => void +): () => void { + listeners.add(listener); + ensureObserver(); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + observer?.disconnect(); + observer = null; + } + }; +} + +function ensureObserver(): void { + if (observer) return; + observer = new MutationObserver(() => { + for (const listener of listeners) listener(); + }); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["src"] + }); +} diff --git a/web/src/nativePreviewNavigator.ts b/web/src/nativePreviewNavigator.ts new file mode 100644 index 0000000..49aca93 --- /dev/null +++ b/web/src/nativePreviewNavigator.ts @@ -0,0 +1,163 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { ComfyApp, ComfyImageResult } from "./types"; +import { + comfyImageReferenceKey, + comfyImageSourceKey +} from "./comfyImageReference"; +import { subscribeNativePreviewLifecycle } from "./nativePreviewLifecycle"; + +interface PreviewNavigationNode { + readonly id?: string | number; + imageIndex?: number | null; + imageRects?: readonly unknown[]; + imgs?: readonly HTMLImageElement[]; + pointerDown?: { index: number | null; pos: [number, number] }; + graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; +} + +/** Open one output through Comfy's native image inspection behavior. */ +export class NativePreviewNavigator { + private pendingReference: ComfyImageResult | undefined; + private legacyInspectionFrame: number | undefined; + private unsubscribeLifecycle: (() => void) | undefined; + + constructor( + private readonly app: ComfyApp, + private readonly node: PreviewNavigationNode + ) {} + + /** Report whether Comfy currently owns a DOM-backed preview surface. */ + usesDomPreview(): boolean { + return this.domRoot() !== null; + } + + /** Inspect one zero-based native preview item in either node renderer. */ + inspect(index: number): void { + if (!Number.isInteger(index) || index < 0) return; + if (this.domRoot()) { + const images = this.images(); + if (!images || index >= images.length) return; + const reference = images[index] as ComfyImageResult; + if (this.clickDomImage(reference)) return; + this.pendingReference = reference; + this.unsubscribeLifecycle ??= subscribeNativePreviewLifecycle(() => { + this.completePendingInspection(); + }); + return; + } + this.scheduleLegacyImage(index); + } + + /** Wait for Comfy to restore canvas images before selecting native detail. */ + private scheduleLegacyImage(index: number): void { + this.cancelLegacyInspection(); + const inspectWhenReady = (): void => { + const images = this.node.imgs; + if (images !== undefined && images.length > index) { + this.legacyInspectionFrame = undefined; + this.openLegacyImage(index); + return; + } + this.legacyInspectionFrame = requestAnimationFrame(inspectWhenReady); + }; + this.legacyInspectionFrame = requestAnimationFrame(inspectWhenReady); + } + + /** Open one loaded image through Comfy's canvas detail state. */ + private openLegacyImage(index: number): void { + this.node.imageIndex = index; + const position = this.app.canvas?.graph_mouse; + if (position) { + this.node.pointerDown = { index, pos: [position[0], position[1]] }; + } + delete this.node.imageRects; + this.node.graph?.setDirtyCanvas?.(true, true); + } + + /** Return the native preview to its one authoritative gallery. */ + showGrid(): void { + this.cancelLegacyInspection(); + this.pendingReference = undefined; + if (this.clickDomGrid()) return; + this.node.imageIndex = null; + delete this.node.imageRects; + this.node.graph?.setDirtyCanvas?.(true, true); + } + + /** Cancel pending navigation when native images are hidden. */ + hide(): void { + this.cancelLegacyInspection(); + this.pendingReference = undefined; + this.node.imageIndex = null; + delete this.node.imageRects; + this.node.graph?.setDirtyCanvas?.(true, true); + } + + /** Release native-preview lifecycle observation. */ + dispose(): void { + this.cancelLegacyInspection(); + this.pendingReference = undefined; + this.unsubscribeLifecycle?.(); + this.unsubscribeLifecycle = undefined; + } + + private cancelLegacyInspection(): void { + if (this.legacyInspectionFrame === undefined) return; + cancelAnimationFrame(this.legacyInspectionFrame); + this.legacyInspectionFrame = undefined; + } + + private completePendingInspection(): void { + const reference = this.pendingReference; + if (!reference || !this.clickDomImage(reference)) return; + this.pendingReference = undefined; + } + + private clickDomImage(reference: ComfyImageResult): boolean { + const root = this.domRoot(); + if (!root) return false; + const image = Array.from(root.querySelectorAll("img")).find( + (candidate) => + comfyImageSourceKey(candidate.src) === comfyImageReferenceKey(reference) + ); + const button = image?.closest("button"); + if (!button) return false; + button.click(); + return true; + } + + private clickDomGrid(): boolean { + const root = this.domRoot(); + if (!root) return false; + const button = Array.from(root.querySelectorAll("button")).find( + (candidate) => + candidate.getAttribute("aria-label") === "Grid view" || + candidate.title === "Grid view" + ); + if (!button) return false; + button.click(); + return true; + } + + private domRoot(): HTMLElement | null { + const nodeId = this.nodeId(); + if (!nodeId) return null; + return ( + Array.from(document.querySelectorAll("[data-node-id]")).find( + (element) => element.dataset.nodeId === nodeId + ) ?? null + ); + } + + private images(): ComfyImageResult[] | undefined { + const nodeId = this.nodeId(); + return nodeId ? this.app.nodeOutputs?.[nodeId]?.images : undefined; + } + + private nodeId(): string | undefined { + return this.node.id === undefined ? undefined : String(this.node.id); + } +} diff --git a/web/src/orderedMediaNode.ts b/web/src/orderedMediaNode.ts new file mode 100644 index 0000000..19c1cc1 --- /dev/null +++ b/web/src/orderedMediaNode.ts @@ -0,0 +1,375 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { NativeNodePreview } from "./nativeNodePreview"; +import { OrderedMediaPreviewController } from "./orderedMediaPreview"; +import { OrderedMediaPreviewActions } from "./orderedMediaPreviewActions"; +import { + normalizeMediaFiles, + OrderedMediaSelection +} from "./orderedMediaSelection"; +import type { + ComfyApi, + ComfyApp, + ComfyNodeExecutionOutput, + Logger +} from "./types"; + +type SelectionIntent = "append" | "replace"; +type NativeCallback = (...args: unknown[]) => unknown; +type WidgetType = "button"; + +export interface OrderedMediaLabels { + readonly singular: string; + readonly plural: string; + readonly replace: string; + readonly add: string; +} + +export interface OrderedMediaNodeConfig { + readonly nodeId: string; + readonly labels: OrderedMediaLabels; + readonly preview: ( + files: string[], + node: OrderedMediaNode + ) => Promise; + readonly onSelectionChanged?: (files: string[]) => void; + readonly previewWidgetNames?: readonly string[]; +} + +interface OrderedMediaWidgetOptions { + canvasOnly?: boolean; + serialize?: boolean; + tooltip?: string; + hidden?: boolean; +} + +interface OrderedMediaWidget { + name: string; + value: unknown; + type?: string; + label?: string; + callback?: (value?: unknown) => void; + computeSize?: (width?: number) => [number, number]; + hidden?: boolean; + options?: OrderedMediaWidgetOptions; +} + +export interface OrderedMediaNode { + constructor: { comfyClass?: string }; + id?: string | number; + widgets?: OrderedMediaWidget[]; + pasteFiles?: NativeCallback; + onDragDrop?: NativeCallback; + onRemoved?: NativeCallback; + onGraphConfigured?: NativeCallback; + onWidgetChanged?: ( + name: string, + value: unknown, + previousValue: unknown, + widget: OrderedMediaWidget + ) => void; + graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; + addWidget( + type: WidgetType, + name: string, + value: string, + callback: (value?: unknown) => void, + options?: OrderedMediaWidgetOptions + ): OrderedMediaWidget; +} + +/** Configure one ordered loader around Comfy's native media preview. */ +export function configureOrderedMediaNode( + candidate: unknown, + app: ComfyApp, + api: ComfyApi, + config: OrderedMediaNodeConfig, + logger: Logger = console +): void { + if (!isOrderedMediaNode(candidate, config.nodeId)) return; + const imageWidget = findWidget(candidate, "image"); + const uploadWidget = findNativeUploadWidget(candidate); + if (!imageWidget || !uploadWidget?.callback) return; + + hideInternalWidget(imageWidget); + hideInternalWidget(uploadWidget); + const selection = new OrderedMediaSelection(imageWidget.value); + const nativePreview = new NativeNodePreview(app, api, candidate); + const preview = new OrderedMediaPreviewController( + nativePreview, + `${config.labels.singular} loader`, + logger + ); + let programmaticSelectionUpdate = false; + let selectionIntent: SelectionIntent = "replace"; + let appendBase: string[] = []; + let uploadPending = false; + let ignoredUploadCallbackFiles: string[] | undefined; + const nativeUploadCallback = uploadWidget.callback; + + const setPersistedFiles = (files: string[]): void => { + programmaticSelectionUpdate = true; + try { + imageWidget.value = [...files]; + } finally { + programmaticSelectionUpdate = false; + } + }; + + const refresh = (files: string[]): void => { + config.onSelectionChanged?.([...files]); + preview.refresh(files, () => config.preview([...files], candidate)); + }; + + const commit = (current: string[], previous: string[]): void => { + setPersistedFiles(current); + candidate.onWidgetChanged?.( + imageWidget.name, + [...current], + [...previous], + imageWidget + ); + candidate.graph?.setDirtyCanvas?.(true, true); + refresh(current); + }; + + const removeAt = (index: number): void => { + const previous = selection.snapshot(); + if (index < 0 || index >= previous.length) return; + const current = selection.remove(index); + commit(current, previous); + }; + + const moveTo = (index: number, destination: number): void => { + const previous = selection.snapshot(); + if ( + index < 0 || + index >= previous.length || + destination < 0 || + destination >= previous.length || + index === destination + ) { + return; + } + const current = selection.move(index, destination); + commit(current, previous); + }; + + const previewActions = new OrderedMediaPreviewActions({ + app, + node: candidate, + preview: nativePreview, + itemLabel: config.labels.singular, + getFiles: () => selection.snapshot(), + moveEarlier: (index) => { + moveTo(index, index - 1); + }, + moveLater: (index) => { + moveTo(index, index + 1); + }, + remove: removeAt + }); + + const beginReplace = (): void => { + selectionIntent = "replace"; + appendBase = []; + uploadPending = true; + }; + const beginAppend = (): void => { + selectionIntent = "append"; + appendBase = selection.snapshot(); + uploadPending = true; + }; + + nativeButton(candidate, "simple_syrup_replace_media", config.labels.replace, () => { + beginReplace(); + nativeUploadCallback.call(uploadWidget); + }); + nativeButton(candidate, "simple_syrup_add_media", config.labels.add, () => { + beginAppend(); + nativeUploadCallback.call(uploadWidget); + }); + + uploadWidget.callback = (value?: unknown) => { + beginReplace(); + nativeUploadCallback.call(uploadWidget, value); + }; + + imageWidget.callback = (value?: unknown) => { + if (programmaticSelectionUpdate) return; + const callbackValue = value ?? imageWidget.value; + if (uploadPending && !Array.isArray(callbackValue)) return; + const incoming = normalizeMediaFiles(callbackValue); + if ( + ignoredUploadCallbackFiles && + sameMediaFiles(incoming, ignoredUploadCallbackFiles) + ) { + ignoredUploadCallbackFiles = undefined; + return; + } + ignoredUploadCallbackFiles = uploadPending ? [...incoming] : undefined; + uploadPending = false; + const current = + selectionIntent === "append" + ? selection.replace([...appendBase, ...incoming]) + : selection.replace(incoming); + selectionIntent = "replace"; + appendBase = []; + if (!sameMediaFiles(current, normalizeMediaFiles(imageWidget.value))) { + setPersistedFiles(current); + } + candidate.graph?.setDirtyCanvas?.(true, true); + refresh(current); + }; + + for (const widgetName of config.previewWidgetNames ?? []) { + const widget = findWidget(candidate, widgetName); + if (!widget) continue; + const originalCallback = widget.callback; + widget.callback = (value?: unknown) => { + originalCallback?.call(widget, value); + if (value !== undefined) widget.value = value; + refresh(selection.snapshot()); + }; + } + + const restoreExternalUploads = wrapExternalAppendUploads(candidate, beginAppend); + const originalOnGraphConfigured = candidate.onGraphConfigured; + candidate.onGraphConfigured = function (...args: unknown[]): unknown { + const result = originalOnGraphConfigured?.apply(this, args); + const restored = selection.replace(imageWidget.value); + if (!Array.isArray(imageWidget.value)) setPersistedFiles(restored); + refresh(restored); + return result; + }; + + const originalOnRemoved = candidate.onRemoved; + candidate.onRemoved = function (...args: unknown[]): unknown { + restoreExternalUploads(); + previewActions.dispose(); + preview.dispose(); + return originalOnRemoved?.apply(this, args); + }; + + const initial = selection.snapshot(); + if (!Array.isArray(imageWidget.value)) setPersistedFiles(initial); + refresh(initial); +} + +/** Register one renderer-neutral ordered-media loader extension. */ +export function registerOrderedMediaNode( + app: ComfyApp, + api: ComfyApi, + extensionName: string, + config: OrderedMediaNodeConfig, + logger: Logger = console +): void { + app.registerExtension({ + name: extensionName, + nodeCreated(candidate: unknown) { + try { + configureOrderedMediaNode(candidate, app, api, config, logger); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + `Could not configure ${config.labels.singular} loader: ${message}`, + error + ); + throw error; + } + } + }); +} + +function nativeButton( + node: OrderedMediaNode, + name: string, + label: string, + callback: () => void +): OrderedMediaWidget { + const widget = node.addWidget("button", name, "ordered_media", callback, { + serialize: false, + tooltip: label + }); + widget.label = label; + return widget; +} + +function findWidget( + node: OrderedMediaNode, + name: string +): OrderedMediaWidget | undefined { + return node.widgets?.find((widget) => widget.name === name); +} + +function findNativeUploadWidget( + node: OrderedMediaNode +): OrderedMediaWidget | undefined { + return node.widgets?.find( + (widget) => + widget.type === "button" && + widget.value === "image" && + widget.options?.serialize === false && + widget.options.canvasOnly === true + ); +} + +function hideInternalWidget(widget: OrderedMediaWidget): void { + widget.options ??= {}; + widget.options.hidden = true; + widget.hidden = true; + widget.computeSize = () => [0, -4]; +} + +function sameMediaFiles(left: string[], right: string[]): boolean { + return ( + left.length === right.length && + left.every((file, index) => file === right[index]) + ); +} + +function wrapExternalAppendUploads( + node: OrderedMediaNode, + beginAppend: () => void +): () => void { + const originalPasteFiles = node.pasteFiles; + const originalOnDragDrop = node.onDragDrop; + const wrappedPasteFiles: NativeCallback | undefined = originalPasteFiles + ? (...args: unknown[]): unknown => { + beginAppend(); + return originalPasteFiles.apply(node, args); + } + : undefined; + const wrappedOnDragDrop: NativeCallback | undefined = originalOnDragDrop + ? (...args: unknown[]): unknown => { + beginAppend(); + return originalOnDragDrop.apply(node, args); + } + : undefined; + if (wrappedPasteFiles) node.pasteFiles = wrappedPasteFiles; + if (wrappedOnDragDrop) node.onDragDrop = wrappedOnDragDrop; + return () => { + if (node.pasteFiles === wrappedPasteFiles) { + if (originalPasteFiles) node.pasteFiles = originalPasteFiles; + else delete node.pasteFiles; + } + if (node.onDragDrop === wrappedOnDragDrop) { + if (originalOnDragDrop) node.onDragDrop = originalOnDragDrop; + else delete node.onDragDrop; + } + }; +} + +function isOrderedMediaNode( + candidate: unknown, + nodeId: string +): candidate is OrderedMediaNode { + if (typeof candidate !== "object" || candidate === null) return false; + const node = candidate as Partial; + return ( + node.constructor?.comfyClass === nodeId && + typeof node.addWidget === "function" + ); +} diff --git a/web/src/orderedMediaPreview.ts b/web/src/orderedMediaPreview.ts new file mode 100644 index 0000000..cb414b3 --- /dev/null +++ b/web/src/orderedMediaPreview.ts @@ -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 + +import type { ComfyNodeExecutionOutput, Logger } from "./types"; + +export type OrderedMediaPreviewClient = () => Promise; + +export interface OrderedMediaPreviewTarget { + publish(output: ComfyNodeExecutionOutput): void; + clear(): void; +} + +/** Publish only the latest ordered-media result through Comfy's native preview. */ +export class OrderedMediaPreviewController { + private requestVersion = 0; + private disposed = false; + private hasPreview = false; + + constructor( + private readonly target: OrderedMediaPreviewTarget, + private readonly label: string, + private readonly logger: Logger = console + ) {} + + /** Resolve one ordered selection without allowing stale previews to win. */ + refresh(files: string[], loadPreview: OrderedMediaPreviewClient): void { + if (this.disposed) return; + const requestVersion = ++this.requestVersion; + if (files.length === 0) { + this.target.clear(); + this.hasPreview = false; + return; + } + void loadPreview() + .then(async (output) => { + await nextTask(); + if (this.disposed || requestVersion !== this.requestVersion) return; + if (!Array.isArray(output.images) || output.images.length !== files.length) { + throw new Error( + `${this.label} preview returned ${String(output.images?.length ?? 0)} images for ${String(files.length)} files.` + ); + } + this.target.publish(output); + this.hasPreview = true; + }) + .catch((error: unknown) => { + if (this.disposed || requestVersion !== this.requestVersion) return; + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Could not refresh ${this.label} preview: ${message}`, error); + if (!this.hasPreview) this.target.clear(); + }); + } + + /** Clear native media and invalidate pending work owned by a removed node. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.requestVersion += 1; + this.target.clear(); + } +} + +function nextTask(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} diff --git a/web/src/orderedMediaPreviewActions.ts b/web/src/orderedMediaPreviewActions.ts new file mode 100644 index 0000000..ddee14f --- /dev/null +++ b/web/src/orderedMediaPreviewActions.ts @@ -0,0 +1,283 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { NativeNodePreview } from "./nativeNodePreview"; +import { subscribeNativePreviewLifecycle } from "./nativePreviewLifecycle"; +import { + OrderedMediaPreviewAffordances, + type NativePreviewSlot +} from "./orderedMediaPreviewAffordances"; +import { + OrderedMediaPreviewTransaction, + type OrderedMediaPreviewItem, + type OrderedMediaPreviewSurface +} from "./orderedMediaPreviewTransaction"; +import type { ComfyApp, ComfyImageResult } from "./types"; +import { + comfyImageReferenceKey, + comfyImageSourceKey +} from "./comfyImageReference"; + +type NativeImageRect = readonly [number, number, number, number]; + +interface ActionPreviewNode { + readonly id?: string | number; + readonly pos?: readonly [number, number]; + readonly flags?: { readonly collapsed?: boolean }; + imageIndex?: number | null; + imageRects?: NativeImageRect[]; + imgs?: HTMLImageElement[]; + graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; +} + +export interface OrderedMediaPreviewActionOptions { + readonly app: ComfyApp; + readonly node: ActionPreviewNode; + readonly preview: NativeNodePreview; + readonly itemLabel: string; + readonly getFiles: () => string[]; + readonly moveEarlier: (index: number) => void; + readonly moveLater: (index: number) => void; + readonly remove: (index: number) => void; +} + +/** Position deterministic list actions over Comfy's native preview cells. */ +export class OrderedMediaPreviewActions { + private readonly affordances: OrderedMediaPreviewAffordances; + private readonly transaction: OrderedMediaPreviewTransaction; + private readonly unsubscribePreview: () => void; + private readonly unsubscribeLifecycle: () => void; + + constructor(private readonly options: OrderedMediaPreviewActionOptions) { + this.transaction = new OrderedMediaPreviewTransaction({ + captureSurface: () => this.captureSurface(), + authoritativeReady: () => this.authoritativeReady(), + stateChanged: () => { + this.affordances.refresh(); + } + }); + this.affordances = new OrderedMediaPreviewAffordances({ + itemLabel: options.itemLabel, + getSlots: () => this.nativeSlots(), + moveEarlier: (index) => { + this.transaction.move(index, index - 1); + options.moveEarlier(index); + }, + moveLater: (index) => { + this.transaction.move(index, index + 1); + options.moveLater(index); + }, + remove: (index) => { + this.transaction.remove(index); + options.remove(index); + } + }); + this.unsubscribePreview = options.preview.subscribe(() => { + this.affordances.refresh(); + this.transaction.authoritativePublished(); + }); + this.unsubscribeLifecycle = subscribeNativePreviewLifecycle(() => { + this.affordances.refresh(); + }); + } + + /** Remove layout listeners and every loader-owned overlay control. */ + dispose(): void { + this.unsubscribePreview(); + this.unsubscribeLifecycle(); + this.transaction.dispose(); + this.affordances.dispose(); + } + + private itemCount(): number { + const files = this.options.getFiles(); + const images = this.currentImages(); + return images?.length === files.length ? files.length : 0; + } + + private currentImages(): ComfyImageResult[] | undefined { + const nodeId = this.nodeId(); + return nodeId ? this.options.app.nodeOutputs?.[nodeId]?.images : undefined; + } + + private domImages(): HTMLImageElement[] { + const root = this.domRoot(); + const references = this.currentImages(); + if (!root || !references || references.length !== this.itemCount()) return []; + const remainingKeys = references.map(comfyImageReferenceKey); + const matched: HTMLImageElement[] = []; + for (const image of Array.from( + root.querySelectorAll("img") + )) { + const key = comfyImageSourceKey(image.src); + const referenceIndex = remainingKeys.indexOf(key); + if (referenceIndex < 0) continue; + matched.push(image); + remainingKeys.splice(referenceIndex, 1); + } + return remainingKeys.length === 0 ? matched : []; + } + + private domRoot(): HTMLElement | null { + const nodeId = this.nodeId(); + if (!nodeId) return null; + return ( + Array.from(document.querySelectorAll("[data-node-id]")).find( + (element) => element.dataset.nodeId === nodeId + ) ?? null + ); + } + + private canvasSlots(): NativePreviewSlot[] { + const canvasApi = this.options.app.canvas; + const nodePosition = this.options.node.pos; + const imageRects = this.options.node.imageRects; + if (!canvasApi || !nodePosition || !imageRects) return []; + const canvasRect = canvasApi.canvas.getBoundingClientRect(); + return imageRects.map(([x, y, width, height]) => { + const topLeft = canvasApi.convertOffsetToCanvas([ + nodePosition[0] + x, + nodePosition[1] + y + ]); + const bottomRight = canvasApi.convertOffsetToCanvas([ + nodePosition[0] + x + width, + nodePosition[1] + y + height + ]); + return { + left: canvasRect.left + topLeft[0], + top: canvasRect.top + topLeft[1], + width: bottomRight[0] - topLeft[0], + height: bottomRight[1] - topLeft[1] + }; + }); + } + + private nativeSlots(): NativePreviewSlot[] { + if ( + this.options.node.flags?.collapsed || + this.options.node.imageIndex != null + ) { + return []; + } + const activeSlots = this.transaction.activeSlots(); + if (activeSlots) return activeSlots; + if (!this.isGridVisible()) return []; + const domSlots = this.domImages().map((image) => { + const rect = image.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height + }; + }); + return domSlots.length === this.itemCount() ? domSlots : this.canvasSlots(); + } + + private captureSurface(): OrderedMediaPreviewSurface | null { + const domImages = this.domImages(); + if (domImages.length > 0) return this.domSurface(domImages); + const canvasImages = this.options.node.imgs; + if (!canvasImages || canvasImages.length !== this.currentImages()?.length) { + return null; + } + return { + items: previewItems(canvasImages), + slots: canvasImages.map((_, index) => () => this.canvasSlots()[index] as NativePreviewSlot), + apply: (items) => { + this.options.node.imgs = items.map((item) => item.image); + delete this.options.node.imageRects; + this.options.node.graph?.setDirtyCanvas?.(true, true); + }, + release: () => undefined + }; + } + + private domSurface(images: HTMLImageElement[]): OrderedMediaPreviewSurface { + const slots = images.map((image) => imageSlot(image)); + const targets = images.map((image) => image.closest("button") ?? image); + const originalDisplays = targets.map((target) => target.style.display); + return { + items: previewItems(images), + slots, + apply: (items) => { + for (const [index, image] of images.entries()) { + const item = items[index]; + const target = targets[index]; + if (!target) continue; + if (!item) { + target.style.display = "none"; + continue; + } + target.style.display = originalDisplays[index] ?? ""; + if (image.src !== item.sourceUrl) image.src = item.sourceUrl; + } + }, + release: () => { + for (const [index, target] of targets.entries()) { + target.style.display = originalDisplays[index] ?? ""; + } + } + }; + } + + private authoritativeReady(): boolean { + const references = this.currentImages(); + if (!references || references.length !== this.options.getFiles().length) { + return false; + } + const expectedKeys = references.map(comfyImageReferenceKey); + const rendered = this.authoritativeImages(expectedKeys); + return ( + rendered.length === expectedKeys.length && + rendered.every( + (image, index) => + comfyImageSourceKey(image.src) === expectedKeys[index] && + image.complete && + image.naturalWidth > 0 + ) + ); + } + + private authoritativeImages(expectedKeys: string[]): HTMLImageElement[] { + const root = this.domRoot(); + const domImages = root + ? Array.from(root.querySelectorAll("img")) + : []; + if (domImages.length === expectedKeys.length) return domImages; + return this.options.node.imgs ?? []; + } + + private isGridVisible(): boolean { + return ( + !this.options.node.flags?.collapsed && + this.options.node.imageIndex == null && + this.itemCount() > 1 + ); + } + + private nodeId(): string | undefined { + const nodeId = this.options.node.id; + return nodeId === undefined ? undefined : String(nodeId); + } +} + +function previewItems(images: HTMLImageElement[]): OrderedMediaPreviewItem[] { + return images.map((image) => ({ + sourceUrl: image.currentSrc || image.src, + image + })); +} + +function imageSlot(image: HTMLImageElement): () => NativePreviewSlot { + return () => { + const rect = image.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height + }; + }; +} diff --git a/web/src/orderedMediaPreviewAffordances.ts b/web/src/orderedMediaPreviewAffordances.ts new file mode 100644 index 0000000..9285d3f --- /dev/null +++ b/web/src/orderedMediaPreviewAffordances.ts @@ -0,0 +1,225 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const MEDIA_MOVE_EARLIER_ATTRIBUTE = "data-ss-media-move-earlier"; +export const MEDIA_MOVE_LATER_ATTRIBUTE = "data-ss-media-move-later"; +export const MEDIA_REMOVE_ATTRIBUTE = "data-ss-media-remove"; +export const MEDIA_INDEX_ATTRIBUTE = "data-ss-media-index"; + +export interface NativePreviewSlot { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +export interface OrderedMediaPreviewAffordanceOptions { + readonly itemLabel: string; + readonly getSlots: () => NativePreviewSlot[]; + readonly moveEarlier: (index: number) => void; + readonly moveLater: (index: number) => void; + readonly remove: (index: number) => void; +} + +interface AffordanceElements { + readonly root: HTMLDivElement; + readonly earlier: HTMLButtonElement; + readonly later: HTMLButtonElement; + readonly remove: HTMLButtonElement; +} + +/** Overlay compact controls without replacing Comfy's native preview surface. */ +export class OrderedMediaPreviewAffordances { + private elements: AffordanceElements[] = []; + private animationFrame: number | null = null; + private remainingSyncFrames = 0; + + constructor(private readonly options: OrderedMediaPreviewAffordanceOptions) { + window.addEventListener("resize", this.requestRefresh, true); + document.addEventListener("click", this.requestRefresh, true); + document.addEventListener("keydown", this.requestRefresh, true); + document.addEventListener("pointermove", this.requestRefresh, true); + document.addEventListener("pointerup", this.requestRefresh, true); + document.addEventListener("wheel", this.requestRefresh, true); + this.refresh(); + } + + /** Recheck native layout across several frames after Comfy rerenders output. */ + refresh(): void { + this.remainingSyncFrames = Math.max(this.remainingSyncFrames, 4); + this.scheduleSync(); + } + + /** Remove every overlay control and global layout listener. */ + dispose(): void { + window.removeEventListener("resize", this.requestRefresh, true); + document.removeEventListener("click", this.requestRefresh, true); + document.removeEventListener("keydown", this.requestRefresh, true); + document.removeEventListener("pointermove", this.requestRefresh, true); + document.removeEventListener("pointerup", this.requestRefresh, true); + document.removeEventListener("wheel", this.requestRefresh, true); + if (this.animationFrame !== null) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + for (const element of this.elements) element.root.remove(); + this.elements = []; + } + + private readonly requestRefresh = (): void => { + this.remainingSyncFrames = Math.max(this.remainingSyncFrames, 2); + this.scheduleSync(); + }; + + private scheduleSync(): void { + if (this.animationFrame !== null) return; + this.animationFrame = requestAnimationFrame(() => { + this.animationFrame = null; + this.sync(); + this.remainingSyncFrames -= 1; + if (this.remainingSyncFrames > 0) this.scheduleSync(); + }); + } + + private sync(): void { + const slots = this.options.getSlots(); + this.resizeElements(slots.length); + for (const [index, elements] of this.elements.entries()) { + const slot = slots[index]; + if (!slot || slot.width <= 0 || slot.height <= 0) { + elements.root.hidden = true; + continue; + } + elements.root.hidden = false; + const stripHeight = Math.max(18, Math.min(26, slot.height * 0.16)); + Object.assign(elements.root.style, { + left: `${String(slot.left)}px`, + top: `${String(slot.top)}px`, + width: `${String(slot.width)}px`, + height: `${String(stripHeight)}px` + }); + elements.root.dataset.ssMediaIndex = String(index); + elements.earlier.dataset.ssMediaIndex = String(index); + elements.later.dataset.ssMediaIndex = String(index); + elements.remove.dataset.ssMediaIndex = String(index); + setActionAvailability(elements.earlier, index > 0); + setActionAvailability( + elements.later, + index < this.elements.length - 1 + ); + elements.earlier.setAttribute( + "aria-label", + `Move ${this.options.itemLabel} ${String(index + 1)} earlier` + ); + elements.later.setAttribute( + "aria-label", + `Move ${this.options.itemLabel} ${String(index + 1)} later` + ); + elements.remove.setAttribute( + "aria-label", + `Remove ${this.options.itemLabel} ${String(index + 1)}` + ); + } + } + + private resizeElements(count: number): void { + while (this.elements.length > count) this.elements.pop()?.root.remove(); + while (this.elements.length < count) { + this.elements.push(this.createElements()); + } + } + + private createElements(): AffordanceElements { + const root = document.createElement("div"); + root.className = "ss-native-preview-affordance"; + Object.assign(root.style, { + position: "fixed", + zIndex: "9990", + display: "flex", + alignItems: "stretch", + pointerEvents: "none", + overflow: "hidden", + borderRadius: "4px 4px 0 0", + background: "rgba(20, 20, 20, 0.72)", + boxShadow: "inset 0 -1px 0 rgba(255, 255, 255, 0.16)" + }); + const earlier = controlButton("pi-arrow-left", "Move earlier"); + earlier.setAttribute(MEDIA_MOVE_EARLIER_ATTRIBUTE, "true"); + const later = controlButton("pi-arrow-right", "Move later"); + later.setAttribute(MEDIA_MOVE_LATER_ATTRIBUTE, "true"); + const remove = controlButton("pi-times", "Remove"); + remove.setAttribute(MEDIA_REMOVE_ATTRIBUTE, "true"); + bindAction(earlier, (index) => { + this.options.moveEarlier(index); + }); + bindAction(later, (index) => { + this.options.moveLater(index); + }); + bindAction(remove, (index) => { + this.options.remove(index); + }); + root.append(earlier, later, remove); + document.body.append(root); + return { root, earlier, later, remove }; + } +} + +/** Return the current positional index carried by one overlay control. */ +export function mediaIndex(element: Element): number | null { + const value = element.getAttribute(MEDIA_INDEX_ATTRIBUTE); + if (value === null) return null; + const index = Number(value); + return Number.isInteger(index) && index >= 0 ? index : null; +} + +function controlButton(iconClass: string, title: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.title = title; + const icon = document.createElement("i"); + icon.className = `pi ${iconClass}`; + icon.setAttribute("aria-hidden", "true"); + button.append(icon); + Object.assign(button.style, { + appearance: "none", + border: "0", + padding: "0", + margin: "0", + minWidth: "0", + flex: "1 1 0", + color: "rgba(255, 255, 255, 0.92)", + background: "transparent", + fontSize: "13px", + cursor: "pointer", + pointerEvents: "auto" + }); + return button; +} + +function bindAction( + button: HTMLButtonElement, + action: (index: number) => void +): void { + button.addEventListener("pointerdown", stopControlPointerEvent); + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const index = mediaIndex(button); + if (index !== null) action(index); + }); +} + +function setActionAvailability( + button: HTMLButtonElement, + available: boolean +): void { + button.disabled = !available; + button.style.cursor = available ? "pointer" : "default"; + button.style.opacity = available ? "1" : "0.35"; +} + +function stopControlPointerEvent(event: PointerEvent): void { + event.preventDefault(); + event.stopPropagation(); +} diff --git a/web/src/orderedMediaPreviewTransaction.ts b/web/src/orderedMediaPreviewTransaction.ts new file mode 100644 index 0000000..8006600 --- /dev/null +++ b/web/src/orderedMediaPreviewTransaction.ts @@ -0,0 +1,116 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { NativePreviewSlot } from "./orderedMediaPreviewAffordances"; + +export interface OrderedMediaPreviewItem { + readonly sourceUrl: string; + readonly image: HTMLImageElement; +} + +export interface OrderedMediaPreviewSurface { + readonly items: OrderedMediaPreviewItem[]; + readonly slots: Array<() => NativePreviewSlot>; + readonly apply: (items: OrderedMediaPreviewItem[]) => void; + readonly release: () => void; +} + +export interface OrderedMediaPreviewTransactionOptions { + readonly captureSurface: () => OrderedMediaPreviewSurface | null; + readonly authoritativeReady: () => boolean; + readonly stateChanged: () => void; +} + +/** Reorder Comfy's loaded preview media without creating another drawing layer. */ +export class OrderedMediaPreviewTransaction { + private surface: OrderedMediaPreviewSurface | null = null; + private items: OrderedMediaPreviewItem[] | null = null; + private handoffFrame: number | null = null; + + constructor(private readonly options: OrderedMediaPreviewTransactionOptions) {} + + /** Move an already-loaded native item without rebuilding its gallery. */ + move(index: number, destination: number): boolean { + if (!this.begin()) return false; + const items = this.items; + if ( + !items || + index < 0 || + index >= items.length || + destination < 0 || + destination >= items.length || + index === destination + ) { + return false; + } + const [moved] = items.splice(index, 1); + if (!moved) return false; + items.splice(destination, 0, moved); + this.apply(); + return true; + } + + /** Remove an already-loaded item and let the native gallery compact itself. */ + remove(index: number): boolean { + if (!this.begin()) return false; + const items = this.items; + if (!items || index < 0 || index >= items.length) return false; + items.splice(index, 1); + this.apply(); + return true; + } + + /** Return live native-cell geometry while an optimistic transaction is active. */ + activeSlots(): NativePreviewSlot[] | null { + if (!this.surface || !this.items) return null; + return this.surface.slots.slice(0, this.items.length).map((slot) => slot()); + } + + /** End the transaction after Comfy's authoritative native preview is ready. */ + authoritativePublished(): void { + if (!this.surface || this.handoffFrame !== null) return; + this.scheduleHandoff(); + } + + /** Release native surface changes and stop any pending handoff check. */ + dispose(): void { + if (this.handoffFrame !== null) cancelAnimationFrame(this.handoffFrame); + this.handoffFrame = null; + this.finish(); + } + + private begin(): boolean { + if (this.surface && this.items) return true; + const surface = this.options.captureSurface(); + if (!surface || surface.items.length === 0) return false; + this.surface = surface; + this.items = [...surface.items]; + return true; + } + + private apply(): void { + if (!this.surface || !this.items) return; + this.surface.apply(this.items); + this.options.stateChanged(); + } + + private scheduleHandoff(): void { + this.handoffFrame = requestAnimationFrame(() => { + this.handoffFrame = null; + if (!this.surface) return; + if (this.options.authoritativeReady()) { + this.finish(); + this.options.stateChanged(); + return; + } + this.scheduleHandoff(); + }); + } + + private finish(): void { + this.surface?.release(); + this.surface = null; + this.items = null; + } +} diff --git a/web/src/orderedMediaSelection.ts b/web/src/orderedMediaSelection.ts new file mode 100644 index 0000000..f81c273 --- /dev/null +++ b/web/src/orderedMediaSelection.ts @@ -0,0 +1,56 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** Own an ordered, duplicate-preserving collection of uploaded file references. */ +export class OrderedMediaSelection { + private files: string[]; + + constructor(value: unknown = []) { + this.files = normalizeMediaFiles(value); + } + + /** Return a defensive snapshot in authored order. */ + snapshot(): string[] { + return [...this.files]; + } + + /** Replace every position with a normalized persisted widget value. */ + replace(value: unknown): string[] { + this.files = normalizeMediaFiles(value); + return this.snapshot(); + } + + /** Append every incoming position without deduplicating filenames. */ + append(value: unknown): string[] { + this.files.push(...normalizeMediaFiles(value)); + return this.snapshot(); + } + + /** Remove one exact position when it exists. */ + remove(index: number): string[] { + if (validIndex(index, this.files.length)) this.files.splice(index, 1); + return this.snapshot(); + } + + /** Move one exact position and retain all duplicate filenames. */ + move(from: number, to: number): string[] { + if (!validIndex(from, this.files.length) || !validIndex(to, this.files.length)) { + return this.snapshot(); + } + const [moved] = this.files.splice(from, 1); + if (moved !== undefined) this.files.splice(to, 0, moved); + return this.snapshot(); + } +} +/** Narrow scalar or multiselect widget state to valid ordered file paths. */ +export function normalizeMediaFiles(value: unknown): string[] { + const values = Array.isArray(value) ? value : [value]; + return values.filter( + (item): item is string => typeof item === "string" && item.length > 0 + ); +} + +function validIndex(index: number, length: number): boolean { + return Number.isInteger(index) && index >= 0 && index < length; +} diff --git a/web/src/segPreviewInspector.ts b/web/src/segPreviewInspector.ts index b66c81a..db01ee5 100644 --- a/web/src/segPreviewInspector.ts +++ b/web/src/segPreviewInspector.ts @@ -7,19 +7,18 @@ import { type PreparedInspectorState } from "./interactiveInspector"; import { ImageViewport } from "./imageViewport"; -import { maskAtlasFromImage } from "./maskAtlas"; -import type { MaskAtlas } from "./maskAtlas"; +import { maskAtlasFromImage, type MaskAtlas } from "./maskAtlas"; +import { comfyImageUrl } from "./comfyImageUrl"; import type { SegPreviewDocument, SegPreviewRegion } from "./segPreviewTypes"; -import { previewAssetUrl } from "./segPreviewTypes"; import { SelectionModel, type SelectionState } from "./selectionModel"; -type PreviewMode = "overlay" | "grid"; export type PreviewImageLoader = (url: string) => Promise; +export type SegPreviewMode = "overlay" | "grid"; -interface CommittedPreview { +interface CommittedOverlay { document: SegPreviewDocument; image: HTMLImageElement; atlas: MaskAtlas; @@ -28,23 +27,23 @@ interface CommittedPreview { selection: SelectionModel; } -/** Render the interactive SEGS inspector inside Comfy's native DOM widget. */ +/** Render the SEGS overlay and switch visibility to Comfy's native gallery. */ export class SegPreviewInspector implements AsyncInspectorView { readonly element = document.createElement("section"); - private readonly body = document.createElement("div"); - private readonly status = document.createElement("div"); private readonly overlayButton = modeButton("Overlay"); private readonly gridButton = modeButton("Grid"); - private mode: PreviewMode = "overlay"; - private committed: CommittedPreview | undefined; + private readonly body = document.createElement("div"); + private readonly status = document.createElement("div"); + private mode: SegPreviewMode = "overlay"; + private committed: CommittedOverlay | undefined; private unsubscribeSelection: (() => void) | undefined; private highlightCanvas: HTMLCanvasElement | undefined; - private focusContainer: HTMLElement | undefined; - private gridButtons = new Map(); constructor( + private readonly inspectRegion: (index: number) => void = () => undefined, + private readonly changeMode: (mode: SegPreviewMode) => void = () => undefined, private readonly apiURL: (path: string) => string = (path) => path, private readonly loadImage: PreviewImageLoader = loadPreviewImage ) { @@ -64,11 +63,28 @@ export class SegPreviewInspector this.setMode("grid"); }); this.element.append(toolbar, this.body, this.status); - this.updateModeButtons(); + this.updateMode(); this.showMessage("Run the workflow to inspect SEGS."); } - /** Show a native loading state while execution assets are decoded. */ + /** Return the renderer height needed by the active preview surface. */ + preferredHeight(): number { + return this.mode === "overlay" ? 360 : 38; + } + + /** Select the overlay or native gallery and optionally notify its owner. */ + setMode(mode: SegPreviewMode, notifyOwner = true): void { + if (mode === this.mode) { + if (notifyOwner) this.changeMode(mode); + return; + } + this.mode = mode; + this.updateMode(); + this.renderMode(); + if (notifyOwner) this.changeMode(mode); + } + + /** Show a loading state while execution assets are decoded. */ setLoading(): void { this.showMessage("Loading SEGS preview…"); } @@ -76,8 +92,8 @@ export class SegPreviewInspector /** Load source and atlas assets without publishing partial state. */ async prepare(document: SegPreviewDocument): Promise { const [image, atlasImage] = await Promise.all([ - this.loadImage(previewAssetUrl(document.preview.image, this.apiURL)), - this.loadImage(previewAssetUrl(document.atlas.image, this.apiURL)) + this.loadImage(comfyImageUrl(document.preview.image, this.apiURL)), + this.loadImage(comfyImageUrl(document.atlas.image, this.apiURL)) ]); const atlas = maskAtlasFromImage(document, atlasImage); let disposed = false; @@ -91,16 +107,17 @@ export class SegPreviewInspector }; } - /** Replace the inspector with an actionable failure message. */ + /** Show an actionable error without breaking the node lifecycle. */ showError(message: string): void { this.showMessage(message, true); } - /** Release node-owned DOM and interaction subscriptions. */ + /** Release selection subscriptions and rendered state. */ dispose(): void { this.unsubscribeSelection?.(); this.unsubscribeSelection = undefined; this.committed = undefined; + this.highlightCanvas = undefined; this.body.replaceChildren(); this.status.textContent = ""; } @@ -132,36 +149,20 @@ export class SegPreviewInspector this.renderMode(); } - private setMode(mode: PreviewMode): void { - if (this.mode === mode) return; - this.mode = mode; - this.updateModeButtons(); - this.renderMode(); - } - - private updateModeButtons(): void { - this.overlayButton.setAttribute( - "aria-pressed", - String(this.mode === "overlay") - ); - this.gridButton.setAttribute("aria-pressed", String(this.mode === "grid")); - } - private renderMode(): void { + const committed = this.committed; + if (!committed) return; + if (this.mode === "overlay") { + this.renderOverlay(); + return; + } this.highlightCanvas = undefined; - this.focusContainer = undefined; - this.gridButtons.clear(); - if (!this.committed) return; - this.body.replaceChildren( - this.mode === "overlay" ? this.overlayView() : this.gridView() - ); - this.renderSelection(this.committed.selection.state()); + this.body.replaceChildren(); + this.status.textContent = `${String(committed.document.regions.length)} regions`; } - private overlayView(): HTMLElement { + private renderOverlay(): void { const committed = required(this.committed); - const container = document.createElement("div"); - container.className = "ss-segs-preview__overlay-view"; const stack = document.createElement("div"); stack.className = "ss-segs-preview__canvas-stack"; const base = sizedCanvas(committed.document.preview); @@ -194,108 +195,47 @@ export class SegPreviewInspector committed.selection.clearHover(); }); highlight.addEventListener("click", () => { - committed.selection.selectNextCandidate(); + const selected = committed.selection.selectNextCandidate(); + const region = committed.document.regions.find( + (candidate) => candidate.id === selected + ); + if (region) { + this.setMode("grid", false); + this.inspectRegion(region.index); + } + committed.selection.select(null); }); stack.append(base, highlight); - this.focusContainer = document.createElement("div"); - this.focusContainer.className = "ss-segs-preview__focus"; - container.append(stack, this.focusContainer); - return container; - } - - private gridView(): HTMLElement { - const committed = required(this.committed); - const grid = document.createElement("div"); - grid.className = "ss-segs-preview__grid"; - grid.setAttribute("role", "listbox"); - if (committed.document.regions.length === 0) { - const empty = document.createElement("div"); - empty.className = "ss-segs-preview__empty"; - empty.textContent = "No SEGS to display."; - grid.append(empty); - return grid; - } - for (const region of committed.document.regions) { - const card = document.createElement("button"); - card.type = "button"; - card.className = "ss-segs-preview__card"; - card.setAttribute("role", "option"); - card.dataset.regionId = region.id; - const canvas = regionPreviewCanvas(committed, region, 160, 132); - const label = document.createElement("span"); - label.textContent = regionTitle(region); - card.append(canvas, label); - card.addEventListener("pointerenter", () => { - committed.selection.hover([region.id]); - }); - card.addEventListener("pointerleave", () => { - committed.selection.clearHover(); - }); - card.addEventListener("click", () => { - committed.selection.select(region.id); - }); - this.gridButtons.set(region.id, card); - grid.append(card); - } - return grid; + this.body.replaceChildren(stack); + this.renderSelection(committed.selection.state()); } private renderSelection(state: SelectionState): void { const committed = this.committed; - if (!committed) return; + const highlight = this.highlightCanvas; + if (!committed || !highlight) return; const active = state.active ? committed.document.regions.find((region) => region.id === state.active) : undefined; - if (this.highlightCanvas) { - const context = context2d(this.highlightCanvas); - context.clearRect( - 0, - 0, - this.highlightCanvas.width, - this.highlightCanvas.height - ); - if (active) { - context.save(); - context.globalAlpha = 0.9; - context.shadowColor = "rgba(255, 255, 255, 0.95)"; - context.shadowBlur = 8; - drawRegionMask(context, committed, active); - context.restore(); - } + const context = context2d(highlight); + context.clearRect(0, 0, highlight.width, highlight.height); + if (active) { + context.save(); + context.globalAlpha = 0.9; + context.shadowColor = "rgba(255, 255, 255, 0.95)"; + context.shadowBlur = 8; + drawRegionMask(context, committed, active); + context.restore(); } - for (const [id, button] of this.gridButtons) { - const selected = id === state.selected; - const highlighted = id === active?.id; - button.setAttribute("aria-selected", String(selected)); - button.dataset.highlighted = String(highlighted); - } - this.renderFocus(active); if (!active) { this.status.textContent = `${String(committed.document.regions.length)} regions`; } else if (state.candidates.length > 1) { - this.status.textContent = `${regionTitle(active)} · ${String(state.candidates.length)} overlapping regions · click to cycle`; + this.status.textContent = `${regionTitle(active)} · ${String(state.candidates.length)} overlapping regions · click to inspect`; } else { - this.status.textContent = regionTitle(active); + this.status.textContent = `${regionTitle(active)} · click to inspect`; } } - private renderFocus(region: SegPreviewRegion | undefined): void { - if (!this.focusContainer || !this.committed) return; - if (!region) { - this.focusContainer.replaceChildren(); - return; - } - const title = document.createElement("strong"); - title.textContent = regionTitle(region); - const details = document.createElement("span"); - details.textContent = `${String(Math.round(region.confidence * 100))}% confidence · ${region.area.toLocaleString()} px`; - this.focusContainer.replaceChildren( - regionPreviewCanvas(this.committed, region, 320, 220), - title, - details - ); - } - private showMessage(message: string, error = false): void { const content = document.createElement("div"); content.className = "ss-segs-preview__message"; @@ -304,11 +244,27 @@ export class SegPreviewInspector this.body.replaceChildren(content); this.status.textContent = ""; } + + private updateMode(): void { + this.element.dataset.mode = this.mode; + this.overlayButton.setAttribute( + "aria-pressed", + String(this.mode === "overlay") + ); + this.gridButton.setAttribute("aria-pressed", String(this.mode === "grid")); + } +} + +function modeButton(label: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + return button; } function drawRegionMask( context: CanvasRenderingContext2D, - committed: CommittedPreview, + committed: CommittedOverlay, region: SegPreviewRegion ): void { const placement = committed.viewport.displayRectangle(region.crop); @@ -323,36 +279,6 @@ function drawRegionMask( ); } -function regionPreviewCanvas( - committed: CommittedPreview, - region: SegPreviewRegion, - maximumWidth: number, - maximumHeight: number -): HTMLCanvasElement { - const aspect = region.crop.width / region.crop.height; - const width = Math.max(80, Math.min(maximumWidth, Math.round(maximumHeight * aspect))); - const height = Math.max(64, Math.min(maximumHeight, Math.round(width / aspect))); - const canvas = sizedCanvas({ width, height }); - const context = context2d(canvas); - const source = committed.viewport.displayRectangle(region.crop); - context.drawImage( - committed.image, - source.x, - source.y, - source.width, - source.height, - 0, - 0, - width, - height - ); - context.globalAlpha = 0.45; - const mask = committed.masks.get(region.id); - if (mask) context.drawImage(mask, 0, 0, width, height); - context.globalAlpha = 1; - return canvas; -} - function maskCanvas(atlas: MaskAtlas, region: SegPreviewRegion): HTMLCanvasElement { const canvas = sizedCanvas(region.atlas); context2d(canvas).putImageData(atlas.coloredMask(region), 0, 0); @@ -377,13 +303,6 @@ function regionTitle(region: SegPreviewRegion): string { return `${String(region.index + 1)}. ${label || "region"}`; } -function modeButton(label: string): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = label; - return button; -} - function required(value: T | undefined): T { if (!value) throw new Error("Simple Preview SEGS has no committed document."); return value; @@ -413,25 +332,19 @@ function installStyles(): void { const style = document.createElement("style"); style.dataset.simpleSyrupSegsPreview = "true"; style.textContent = ` - .ss-segs-preview { box-sizing: border-box; width: 100%; min-height: 360px; color: var(--fg-color, #ddd); font: 12px sans-serif; } + .ss-segs-preview { box-sizing: border-box; width: 100%; color: var(--fg-color, #ddd); font: 12px sans-serif; } .ss-segs-preview * { box-sizing: border-box; } .ss-segs-preview__toolbar { display: flex; gap: 4px; margin: 0 0 6px; } .ss-segs-preview__toolbar button { flex: 1; min-height: 26px; border: 1px solid var(--border-color, #555); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; } .ss-segs-preview__toolbar button[aria-pressed="true"] { border-color: var(--p-primary-color, #6aa9ff); background: color-mix(in srgb, var(--p-primary-color, #6aa9ff) 28%, var(--comfy-input-bg, #222)); } - .ss-segs-preview__body { min-height: 320px; overflow: hidden; border: 1px solid var(--border-color, #444); border-radius: 6px; background: var(--comfy-menu-bg, #181818); } + .ss-segs-preview__body { overflow: hidden; border: 1px solid var(--border-color, #444); border-radius: 6px; background: var(--comfy-menu-bg, #181818); } + .ss-segs-preview[data-mode="grid"] .ss-segs-preview__body { display: none; } + .ss-segs-preview[data-mode="grid"] .ss-segs-preview__status { display: none; } .ss-segs-preview__canvas-stack { position: relative; line-height: 0; background: #111; } .ss-segs-preview__canvas-stack canvas { display: block; width: 100%; height: auto; } .ss-segs-preview__highlight { position: absolute; inset: 0; cursor: crosshair; } .ss-segs-preview__status { min-height: 22px; padding: 5px 2px 0; color: var(--descrip-text, #aaa); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .ss-segs-preview__focus { display: grid; grid-template-columns: minmax(96px, 42%) 1fr; gap: 3px 9px; align-items: start; padding: 7px; border-top: 1px solid var(--border-color, #444); } - .ss-segs-preview__focus canvas { grid-row: 1 / span 2; width: 100%; height: auto; border-radius: 4px; background: #111; } - .ss-segs-preview__focus span { color: var(--descrip-text, #aaa); } - .ss-segs-preview__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 6px; max-height: 430px; padding: 7px; overflow: auto; } - .ss-segs-preview__card { min-width: 0; padding: 4px; border: 1px solid var(--border-color, #444); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; text-align: left; } - .ss-segs-preview__card[data-highlighted="true"], .ss-segs-preview__card[aria-selected="true"] { border-color: var(--p-primary-color, #6aa9ff); box-shadow: 0 0 0 1px var(--p-primary-color, #6aa9ff); } - .ss-segs-preview__card canvas { display: block; width: 100%; height: 94px; object-fit: contain; margin-bottom: 4px; background: #111; } - .ss-segs-preview__card span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .ss-segs-preview__message, .ss-segs-preview__empty { display: grid; min-height: 320px; place-items: center; padding: 18px; color: var(--descrip-text, #aaa); text-align: center; } + .ss-segs-preview__message { display: grid; min-height: 160px; place-items: center; padding: 18px; color: var(--descrip-text, #aaa); text-align: center; } .ss-segs-preview__message[data-error="true"] { color: var(--error-text, #ff8a80); } `; document.head.append(style); diff --git a/web/src/segPreviewNativeSurface.ts b/web/src/segPreviewNativeSurface.ts new file mode 100644 index 0000000..4d6c529 --- /dev/null +++ b/web/src/segPreviewNativeSurface.ts @@ -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 + +import { NativeNodePreview } from "./nativeNodePreview"; +import { NativePreviewNavigator } from "./nativePreviewNavigator"; +import { loadedImagesMatchReferences } from "./comfyImageReference"; +import { parseSegPreviewDocument } from "./segPreviewTypes"; +import type { + ComfyApi, + ComfyApp, + ComfyImageResult, + ComfyNodeExecutionOutput +} from "./types"; + +interface NativePreviewNode { + readonly id?: string | number; + imageIndex?: number | null; + imageRects?: readonly unknown[]; + images?: ComfyImageResult[]; + imgs?: HTMLImageElement[]; + widgets?: Array<{ + name: string; + hidden?: boolean; + options?: { hidden?: boolean }; + }>; + graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; +} + +type SegPreviewExecutionOutput = ComfyNodeExecutionOutput & + Record & { + images: ComfyImageResult[]; + }; + +type SegPreviewSurfaceMode = "overlay" | "grid" | "detail"; + +/** Coordinate the overlay with one Comfy-owned grid and detail surface. */ +export class SegPreviewNativeSurface { + private readonly preview: NativeNodePreview; + private readonly navigator: NativePreviewNavigator; + private authoritativeOutput: SegPreviewExecutionOutput | undefined; + private mode: SegPreviewSurfaceMode = "overlay"; + + constructor( + private readonly app: ComfyApp, + api: ComfyApi, + private readonly node: NativePreviewNode + ) { + this.preview = new NativeNodePreview(app, api, node); + this.navigator = new NativePreviewNavigator(app, node); + } + + /** Accept a complete backend result and apply the active surface mode. */ + update(value: unknown): void { + const output = executionOutput(value); + if (!output) return; + const document = parseSegPreviewDocument(value); + if (!document) return; + if (output.images.length !== document.regions.length) return; + this.authoritativeOutput = output; + if (this.mode === "overlay") { + this.hideNativePreview(); + } else if (this.mode === "grid") { + this.navigator.showGrid(); + } + } + + /** Show only the custom semantic overlay. */ + showOverlay(): void { + this.mode = "overlay"; + this.hideNativePreview(); + } + + /** Show Comfy's native gallery and return it from native detail mode. */ + showGrid(): void { + this.mode = "grid"; + this.setCanvasPreviewVisible(true); + if (this.nativeImagesNeedPublishing()) { + this.publishAuthoritativeOutput(); + } + this.navigator.showGrid(); + } + + /** Show one SEG through Comfy's native detail viewer. */ + inspect(index: number): void { + const output = this.authoritativeOutput; + if (!output || index < 0 || index >= output.images.length) return; + this.mode = "detail"; + this.setCanvasPreviewVisible(true); + if (this.nativeImagesNeedPublishing()) { + this.publishAuthoritativeOutput(); + } + this.navigator.inspect(index); + } + + /** Release native-preview observers owned by this node. */ + dispose(): void { + this.navigator.dispose(); + } + + private hideNativePreview(): void { + const usesDomPreview = this.navigator.usesDomPreview(); + this.navigator.hide(); + this.setCanvasPreviewVisible(false); + if (!usesDomPreview) return; + this.node.imgs = []; + const output = this.authoritativeOutput; + if (!output || output.images.length === 0) return; + this.preview.publish({ + ...output, + images: [], + animated: [] + }); + } + + /** Hide the persistent Nodes 1.0 preview widget outside native modes. */ + private setCanvasPreviewVisible(visible: boolean): void { + const widget = this.node.widgets?.find( + (candidate) => candidate.name === "$$canvas-image-preview" + ); + if (!widget) return; + widget.hidden = !visible; + widget.options ??= {}; + widget.options.hidden = !visible; + } + + private publishAuthoritativeOutput(): void { + const output = this.authoritativeOutput; + if (!output) return; + delete this.node.images; + delete this.node.imgs; + this.preview.publish({ + ...output, + images: [...output.images] + }); + } + + /** Check whether Comfy's current output still contains every SEG image. */ + private hasAuthoritativeImages(): boolean { + const output = this.authoritativeOutput; + if (!output || this.node.id === undefined) return false; + return ( + this.app.nodeOutputs?.[String(this.node.id)]?.images?.length === + output.images.length + ); + } + + /** Decide whether the active renderer needs its native images republished. */ + private nativeImagesNeedPublishing(): boolean { + const output = this.authoritativeOutput; + if (!output) return false; + if (this.navigator.usesDomPreview()) return !this.hasAuthoritativeImages(); + return !loadedImagesMatchReferences(this.node.imgs, output.images); + } +} + +function executionOutput(value: unknown): SegPreviewExecutionOutput | undefined { + if (typeof value !== "object" || value === null) return undefined; + const output = value as Record; + if (!Array.isArray(output.images) || !output.images.every(isImageResult)) { + return undefined; + } + return output as SegPreviewExecutionOutput; +} + +function isImageResult(value: unknown): value is ComfyImageResult { + if (typeof value !== "object" || value === null) return false; + const image = value as Partial; + return ( + typeof image.filename === "string" && + typeof image.subfolder === "string" && + (image.type === "input" || image.type === "output" || image.type === "temp") + ); +} diff --git a/web/src/segPreviewNode.ts b/web/src/segPreviewNode.ts index 744ad75..58ef549 100644 --- a/web/src/segPreviewNode.ts +++ b/web/src/segPreviewNode.ts @@ -3,7 +3,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { InteractiveInspectorController } from "./interactiveInspector"; +import { FixedDomWidgetLayout } from "./domWidgetLayout"; import { SegPreviewInspector, type PreviewImageLoader } from "./segPreviewInspector"; +import { SegPreviewNativeSurface } from "./segPreviewNativeSurface"; import { parseSegPreviewDocument, type SegPreviewDocument @@ -21,6 +23,14 @@ const SIMPLE_PREVIEW_SEGS_NODE_ID = "SimpleSyrup.SimplePreviewSEGS"; interface DomWidget { serialize?: boolean; computeSize?: (width?: number) => [number, number]; + computeLayoutSize?: + | ((node: unknown) => { + minHeight: number; + maxHeight?: number; + minWidth: number; + maxWidth?: number; + }) + | undefined; options?: { serialize?: boolean; canvasOnly?: boolean; @@ -30,6 +40,11 @@ interface DomWidget { interface PreviewNode { constructor: { comfyClass?: string }; id?: string | number; + imageIndex?: number | null; + imageRects?: readonly unknown[]; + imgs?: HTMLImageElement[]; + graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; + arrange?: () => void; size?: [number, number]; onExecuted?: (output: unknown) => void; onGraphConfigured?: (...args: unknown[]) => unknown; @@ -44,7 +59,7 @@ interface PreviewNode { ): DomWidget; } -/** Register the renderer-neutral interactive SEGS inspector node widget. */ +/** Register the SEGS overlay while delegating browsing to Comfy's native preview. */ export function registerSimplePreviewSEGS( app: ComfyApp, api: ComfyApi, @@ -55,12 +70,25 @@ export function registerSimplePreviewSEGS( string, InteractiveInspectorController >(); + const nativeSurfaces = new Map(); + const widgetLayouts = new Map(); const extension: ComfyExtension = { name: "SimpleSyrup.SimplePreviewSEGS", nodeCreated(candidate: unknown) { if (!isPreviewNode(candidate)) return; + const nativeSurface = new SegPreviewNativeSurface(app, api, candidate); + const layoutOwner: { current?: FixedDomWidgetLayout } = {}; const inspector = new SegPreviewInspector( + (index) => { + nativeSurface.inspect(index); + layoutOwner.current?.reflow(); + }, + (mode) => { + if (mode === "overlay") nativeSurface.showOverlay(); + else nativeSurface.showGrid(); + layoutOwner.current?.reflow(); + }, (path) => api.apiURL?.(path) ?? path, loadImage ); @@ -79,11 +107,18 @@ export function registerSimplePreviewSEGS( widget.options ??= {}; widget.options.serialize = false; widget.options.canvasOnly = false; - widget.computeSize = (width = 420) => [Math.max(360, width), 470]; + const widgetLayout = new FixedDomWidgetLayout(candidate, widget, (width = 420) => [ + Math.max(360, width), + inspector.preferredHeight() + ]); + layoutOwner.current = widgetLayout; const registerId = (): void => { if (candidate.id !== undefined) { - controllers.set(String(candidate.id), controller); + const nodeId = String(candidate.id); + controllers.set(nodeId, controller); + nativeSurfaces.set(nodeId, nativeSurface); + widgetLayouts.set(nodeId, widgetLayout); } }; registerId(); @@ -92,6 +127,8 @@ export function registerSimplePreviewSEGS( candidate.onExecuted = function (output: unknown): void { originalExecuted?.call(this, output); controller.update(output); + nativeSurface.update(output); + widgetLayout.reflow(); }; const originalGraphConfigured = candidate.onGraphConfigured; @@ -99,15 +136,24 @@ export function registerSimplePreviewSEGS( const result = originalGraphConfigured?.apply(this, args); registerId(); if (candidate.id !== undefined) { - controller.update(app.nodeOutputs?.[String(candidate.id)]); + const output = app.nodeOutputs?.[String(candidate.id)]; + controller.update(output); + nativeSurface.update(output); + widgetLayout.reflow(); } return result; }; const originalRemoved = candidate.onRemoved; candidate.onRemoved = function (...args: unknown[]): unknown { - if (candidate.id !== undefined) controllers.delete(String(candidate.id)); + if (candidate.id !== undefined) { + const nodeId = String(candidate.id); + controllers.delete(nodeId); + nativeSurfaces.delete(nodeId); + widgetLayouts.delete(nodeId); + } controller.dispose(); + nativeSurface.dispose(); return originalRemoved?.apply(this, args); }; @@ -116,13 +162,15 @@ export function registerSimplePreviewSEGS( if (current && candidate.setSize) { candidate.setSize([ Math.max(420, current[0]), - Math.max(520, computed?.[1] ?? current[1]) + Math.max(420, computed?.[1] ?? current[1]) ]); } }, onNodeOutputsUpdated(outputs: Record) { for (const [nodeId, output] of Object.entries(outputs)) { controllers.get(nodeId)?.update(output); + nativeSurfaces.get(nodeId)?.update(output); + widgetLayouts.get(nodeId)?.reflow(); } } }; diff --git a/web/src/segPreviewTypes.ts b/web/src/segPreviewTypes.ts index 4c1362e..a73ed21 100644 --- a/web/src/segPreviewTypes.ts +++ b/web/src/segPreviewTypes.ts @@ -60,19 +60,6 @@ export function parseSegPreviewDocument( return { version: 1, source, preview, atlas, regions }; } -/** Build a Comfy view URL without assuming it is hosted at the origin root. */ -export function previewAssetUrl( - reference: ComfyImageResult, - apiURL: (path: string) => string = (path) => path -): string { - const query = new URLSearchParams({ - filename: reference.filename, - subfolder: reference.subfolder, - type: reference.type - }); - return apiURL(`/view?${query.toString()}`); -} - function parseRegion(value: unknown, index: number): SegPreviewRegion { if (!isRecord(value)) { throw new Error( diff --git a/web/src/types.ts b/web/src/types.ts index 7650889..d502e27 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -28,6 +28,7 @@ export interface ComfySettingsApi { export interface ComfyApp { nodeOutputs?: Record; + canvas?: ComfyCanvasApi; ui: { settings: ComfySettingsApi; }; @@ -35,6 +36,16 @@ export interface ComfyApp { registerExtension(extension: ComfyExtension): void; } +/** Stable coordinate operations exposed by Comfy's graph canvas. */ +export interface ComfyCanvasApi { + canvas: HTMLCanvasElement; + graph_mouse?: readonly [number, number]; + convertEventToCanvasOffset(event: MouseEvent): [number, number]; + convertOffsetToCanvas( + position: readonly [number, number] + ): [number, number]; +} + export interface ComfyApi extends ComfyExecutionEvents { apiURL?(path: string): string; } diff --git a/web/tests/imageListUpload.test.ts b/web/tests/imageListUpload.test.ts new file mode 100644 index 0000000..a087ab3 --- /dev/null +++ b/web/tests/imageListUpload.test.ts @@ -0,0 +1,31 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it } from "vitest"; + +import { inputImageReference } from "../src/comfyImageUrl"; +import { registerImageListUpload } from "../src/imageListUpload"; +import type { ComfyApp, ComfyExtension } from "../src/types"; + +describe("Load Image List frontend integration", () => { + it("registers its native ordered-preview extension", () => { + let extension: ComfyExtension | undefined; + const app = { + registerExtension(value: ComfyExtension) { + extension = value; + } + } as ComfyApp; + registerImageListUpload(app, new EventTarget()); + + expect(extension?.name).toBe("SimpleSyrup.LoadImageListUpload"); + }); + + it("maps nested and annotated input paths to native preview references", () => { + expect(inputImageReference("references\\subject.png [input]")).toEqual({ + filename: "subject.png", + subfolder: "references", + type: "input" + }); + }); +}); diff --git a/web/tests/maskBatchPreview.test.ts b/web/tests/maskBatchPreview.test.ts deleted file mode 100644 index f03d879..0000000 --- a/web/tests/maskBatchPreview.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -// SimpleSyrup - workflow-focused ComfyUI extensions for image generation -// Copyright (C) 2026 Artificial Sweetener and contributors -// SPDX-License-Identifier: AGPL-3.0-or-later - -import { describe, expect, it, vi } from "vitest"; - -import { - MaskBatchPreviewController, - type MaskBatchPreviewClient -} from "../src/maskBatchPreview"; -import type { - ComfyNodeExecutionOutput, - Logger -} from "../src/types"; - -const OUTPUT: ComfyNodeExecutionOutput = { - images: [{ filename: "mask.png", subfolder: "", type: "temp" }], - animated: [false] -}; - -function deferred(): { - promise: Promise; - resolve: (value: T) => void; -} { - let resolvePromise: ((value: T) => void) | undefined; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - return { - promise, - resolve(value: T) { - if (!resolvePromise) throw new Error("Deferred promise was not initialized."); - resolvePromise(value); - } - }; -} - -describe("MaskBatchPreviewController", () => { - it("clears without requesting a preview for an empty native selection", () => { - const executionEvents = new EventTarget(); - const published: ComfyNodeExecutionOutput[] = []; - executionEvents.addEventListener("executed", (event) => { - published.push( - (event as CustomEvent<{ output: ComfyNodeExecutionOutput }>).detail - .output - ); - }); - const client = vi.fn(); - const clearNativePreview = vi.fn(); - const controller = new MaskBatchPreviewController( - executionEvents, - { id: 3 }, - client, - console, - clearNativePreview - ); - - controller.refresh([], "alpha"); - - expect(published).toEqual([{ images: [], animated: [] }]); - expect(clearNativePreview).toHaveBeenCalledOnce(); - expect(client).not.toHaveBeenCalled(); - }); - - it("publishes through Comfy's native execution pipeline and clears first", async () => { - const node = { id: 4 }; - const executionEvents = new EventTarget(); - const published: ComfyNodeExecutionOutput[] = []; - executionEvents.addEventListener("executed", (event) => { - published.push( - (event as CustomEvent<{ output: ComfyNodeExecutionOutput }>).detail - .output - ); - }); - const client = vi.fn().mockResolvedValue(OUTPUT); - const controller = new MaskBatchPreviewController( - executionEvents, - node, - client - ); - - controller.refresh(["one.png"], "red"); - - expect(published).toEqual([{ images: [], animated: [] }]); - await vi.waitFor(() => { - expect(published.at(-1)).toEqual(OUTPUT); - }); - expect(client).toHaveBeenCalledWith(["one.png"], "red"); - }); - - it("discards responses made stale by a newer channel request", async () => { - const first = deferred(); - const second = deferred(); - const client = vi - .fn() - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise); - const executionEvents = new EventTarget(); - const published: ComfyNodeExecutionOutput[] = []; - executionEvents.addEventListener("executed", (event) => { - published.push( - (event as CustomEvent<{ output: ComfyNodeExecutionOutput }>).detail - .output - ); - }); - const controller = new MaskBatchPreviewController( - executionEvents, - { id: 5 }, - client - ); - const latest: ComfyNodeExecutionOutput = { - images: [{ filename: "blue.png", subfolder: "", type: "temp" }], - animated: [false] - }; - - controller.refresh(["mask.png"], "red"); - controller.refresh(["mask.png"], "blue"); - second.resolve(latest); - await vi.waitFor(() => { - expect(published.at(-1)).toEqual(latest); - }); - first.resolve(OUTPUT); - await Promise.resolve(); - - expect(published.at(-1)).toEqual(latest); - }); - - it("keeps the cleared preview and reports current request failures", async () => { - const error = new Error("preview failed"); - const client = vi.fn().mockRejectedValue(error); - const logger: Logger = { warn: vi.fn() }; - const executionEvents = new EventTarget(); - const published: ComfyNodeExecutionOutput[] = []; - executionEvents.addEventListener("executed", (event) => { - published.push( - (event as CustomEvent<{ output: ComfyNodeExecutionOutput }>).detail - .output - ); - }); - const controller = new MaskBatchPreviewController( - executionEvents, - { id: 6 }, - client, - logger - ); - - controller.refresh(["mask.png"], "alpha"); - - await vi.waitFor(() => { - expect(logger.warn).toHaveBeenCalledWith( - "Could not refresh Load Mask Batch preview.", - error - ); - }); - expect(published).toEqual([{ images: [], animated: [] }]); - }); -}); diff --git a/web/tests/maskBatchUpload.test.ts b/web/tests/maskBatchUpload.test.ts index b49c121..bbfc2a4 100644 --- a/web/tests/maskBatchUpload.test.ts +++ b/web/tests/maskBatchUpload.test.ts @@ -2,499 +2,217 @@ // Copyright (C) 2026 Artificial Sweetener and contributors // SPDX-License-Identifier: AGPL-3.0-or-later -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { configureMaskBatchNode, registerMaskBatchUpload } from "../src/maskBatchUpload"; -import type { MaskBatchPreviewClient } from "../src/maskBatchUpload"; -import type { - ComfyApp, - ComfyExtension, - ComfyNodeExecutionOutput -} from "../src/types"; +import type { ComfyApi, ComfyApp, ComfyExtension } from "../src/types"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("Load Mask Batch ordered-media integration", () => { + it("registers its node-created integration", () => { + let extension: ComfyExtension | undefined; + const app = comfyApp(); + app.registerExtension = (value: ComfyExtension) => { + extension = value; + }; + registerMaskBatchUpload(app, api(), vi.fn()); + + expect(extension?.name).toBe("SimpleSyrup.LoadMaskBatchUpload"); + expect(typeof extension?.nodeCreated).toBe("function"); + }); + + it("renders the selected mask channel through native preview output", async () => { + const fixture = maskNode(["mask.png"]); + const app = comfyApp(); + const preview = vi.fn().mockResolvedValue({ + images: [{ filename: "rendered.png", subfolder: "", type: "temp" }] + }); + configureMaskBatchNode(fixture.node, app, api(), preview); + + await vi.waitFor(() => { + expect(preview).toHaveBeenCalledWith(["mask.png"], "alpha"); + }); + fixture.channelWidget.callback?.("red"); + + await vi.waitFor(() => { + expect(preview).toHaveBeenLastCalledWith(["mask.png"], "red"); + expect(app.nodeOutputs?.["9"]?.images).toEqual([ + { filename: "rendered.png", subfolder: "", type: "temp" } + ]); + }); + }); + + it("removes an exact mask through its thumbnail action", async () => { + const fixture = maskNode(["first.png", "second.png"]); + const preview = maskPreview(); + const app = comfyApp(); + configureMaskBatchNode(fixture.node, app, api(), preview); + const root = document.createElement("section"); + root.dataset.nodeId = "9"; + root.append( + previewImage("first-preview.png", 0), + previewImage("second-preview.png", 90) + ); + document.body.append(root); + + await vi.waitFor(() => { + expect(document.querySelectorAll("[data-ss-media-move-earlier]")).toHaveLength(2); + }); + document + .querySelector("[data-ss-media-remove][data-ss-media-index='1']") + ?.click(); + + expect(fixture.imageWidget.value).toEqual(["first.png"]); + expect(visiblePreviewFiles(root)).toEqual(["first-preview.png"]); + await vi.waitFor(() => { + expect(app.nodeOutputs?.["9"]?.images).toEqual([ + { filename: "first-preview.png", subfolder: "", type: "temp" } + ]); + }); + (fixture.node as { onRemoved?: () => unknown }).onRemoved?.(); + root.remove(); + }); + + it("moves masks through per-thumbnail arrow actions", async () => { + const fixture = maskNode(["first.png", "second.png"]); + const preview = maskPreview(); + const app = comfyApp(); + configureMaskBatchNode(fixture.node, app, api(), preview); + const root = document.createElement("section"); + root.dataset.nodeId = "9"; + root.append( + previewImage("first-preview.png", 0), + previewImage("second-preview.png", 90) + ); + document.body.append(root); + + await vi.waitFor(() => { + expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(2); + }); + document + .querySelectorAll("[data-ss-media-move-later]")[0] + ?.click(); + + expect(fixture.imageWidget.value).toEqual(["second.png", "first.png"]); + expect(visiblePreviewFiles(root)).toEqual([ + "second-preview.png", + "first-preview.png" + ]); + await vi.waitFor(() => { + expect(app.nodeOutputs?.["9"]?.images).toEqual([ + { filename: "second-preview.png", subfolder: "", type: "temp" }, + { filename: "first-preview.png", subfolder: "", type: "temp" } + ]); + }); + (fixture.node as { onRemoved?: () => unknown }).onRemoved?.(); + root.remove(); + }); +}); + +function api(): ComfyApi { + const value = new EventTarget() as ComfyApi; + value.apiURL = (path) => `/base${path}`; + return value; +} + +function comfyApp(): ComfyApp { + return { + nodeOutputs: {}, + registerExtension: vi.fn(), + ui: { settings: { addSetting: vi.fn() } } + }; +} + +function maskNode(files: string[]) { + const imageWidget = widget("image", files, "combo", { canvasOnly: true }); + const channelWidget = widget("channel", "alpha", "combo"); + const uploadWidget = widget("upload", "image", "button", { + serialize: false, + canvasOnly: true + }); + uploadWidget.callback = vi.fn(); + const widgets = [imageWidget, channelWidget, uploadWidget]; + const node = { + constructor: { comfyClass: "SimpleSyrup.LoadMaskBatch" }, + id: 9, + widgets, + graph: { setDirtyCanvas: vi.fn() }, + addWidget( + type: "button" | "combo", + name: string, + value: string, + callback: (value?: unknown) => void, + options?: TestWidget["options"] + ) { + const result = widget(name, value, type, options); + result.callback = callback; + widgets.push(result); + return result; + } + }; + return { channelWidget, imageWidget, node }; +} + +function previewImage(filename: string, left: number): HTMLImageElement { + const image = document.createElement("img"); + image.src = `/api/view?filename=${filename}&subfolder=&type=temp&preview=webp`; + image.getBoundingClientRect = () => new DOMRect(left, 0, 80, 80); + Object.defineProperties(image, { + complete: { configurable: true, value: true }, + naturalWidth: { configurable: true, value: 80 } + }); + return image; +} + +function maskPreview() { + return vi.fn((files: string[]) => + Promise.resolve({ + images: files.map((file) => ({ + filename: file.replace(".png", "-preview.png"), + subfolder: "", + type: "temp" as const + })) + }) + ); +} + +function visiblePreviewFiles(root: HTMLElement): string[] { + return Array.from(root.querySelectorAll("img")) + .filter((image) => image.style.display !== "none") + .map((image) => new URL(image.src).searchParams.get("filename") ?? ""); +} interface TestWidget { name: string; value: unknown; type?: string; label?: string; - callback?: (value?: unknown) => unknown; - computeSize?: (width?: number) => [number, number]; hidden?: boolean; - serializeValue?: () => unknown; - disabled?: boolean; + callback?: (value?: unknown) => void; + computeSize?: (width?: number) => [number, number]; options?: { canvasOnly?: boolean; - disabled?: boolean; serialize?: boolean; tooltip?: string; hidden?: boolean; - values?: string[]; + values?: string[] | (() => string[]); }; } -const PREVIEW_OUTPUT: ComfyNodeExecutionOutput = { - images: [ - { - filename: "ComfyUI_temp_mask.png", - subfolder: "", - type: "temp" - } - ], - animated: [false] -}; - -function createNode(value: unknown = []) { - const nativeUploadCallback = vi.fn(); - const channelCallback = vi.fn(); - const onWidgetChanged = vi.fn(); - const setDirtyCanvas = vi.fn(); - const pasteFiles = vi.fn(() => true); - const onDragDrop = vi.fn(() => Promise.resolve(true)); - const originalOnRemoved = vi.fn(); - const imageWidget: TestWidget = { - name: "image", - value, - type: "combo", - options: { canvasOnly: true } - }; - const channelWidget: TestWidget = { - name: "channel", - value: "alpha", - type: "combo", - callback: channelCallback - }; - const uploadWidget: TestWidget = { - name: "upload", - value: "image", - type: "button", - label: "choose file to upload", - callback: nativeUploadCallback, - options: { serialize: false, canvasOnly: true } - }; - const widgets = [imageWidget, channelWidget, uploadWidget]; - const node = { - constructor: { comfyClass: "SimpleSyrup.LoadMaskBatch" }, - id: 7, - widgets, - properties: {} as Record, - imageIndex: null as number | null, - images: undefined as ComfyNodeExecutionOutput["images"], - imgs: undefined as unknown[] | undefined, - pasteFiles, - onDragDrop, - onRemoved: originalOnRemoved as (...args: unknown[]) => unknown, - onGraphConfigured: undefined as ((...args: unknown[]) => unknown) | undefined, - onWidgetChanged, - graph: { setDirtyCanvas }, - addWidget( - type: "button", - name: string, - widgetValue: string | undefined, - callback: (value?: unknown) => void, - options: TestWidget["options"] - ): TestWidget { - const widget: TestWidget = { - type, - name, - value: widgetValue, - callback - }; - if (options) widget.options = options; - widgets.push(widget); - return widget; - } - }; - return { - channelCallback, - channelWidget, - imageWidget, - nativeUploadCallback, - node, - onDragDrop, - onWidgetChanged, - originalOnRemoved, - pasteFiles, - setDirtyCanvas, - uploadWidget - }; +function widget( + name: string, + value: unknown, + type?: string, + options?: TestWidget["options"] +): TestWidget { + const result: TestWidget = { name, value }; + if (type) result.type = type; + if (options) result.options = options; + return result; } - -function createExecutionEvents(): EventTarget { - return new EventTarget(); -} - -function previewClient( - output: ComfyNodeExecutionOutput = PREVIEW_OUTPUT -): ReturnType> { - return vi.fn().mockResolvedValue(output); -} - -function button(node: ReturnType["node"], name: string) { - const widget = node.widgets.find((candidate) => candidate.name === name); - if (!widget) throw new Error(`Missing test button ${name}.`); - return widget; -} - -function selectFiles(imageWidget: TestWidget, files: string[]): void { - imageWidget.value = files; - imageWidget.callback?.(files); -} - -describe("Load Mask Batch native upload integration", () => { - it("registers a node-created hook", () => { - let extension: ComfyExtension | undefined; - const app = { - registerExtension(value: ComfyExtension) { - extension = value; - } - } as ComfyApp; - - registerMaskBatchUpload(app, createExecutionEvents()); - - expect(extension?.name).toBe("SimpleSyrup.LoadMaskBatchUpload"); - expect(typeof extension?.nodeCreated).toBe("function"); - }); - - it("reports native node-configuration failures with context", () => { - let extension: ComfyExtension | undefined; - const logger = { warn: vi.fn() }; - const app = { - registerExtension(value: ComfyExtension) { - extension = value; - } - } as ComfyApp; - registerMaskBatchUpload(app, createExecutionEvents(), undefined, logger); - const { node } = createNode(); - node.addWidget = () => { - throw new Error("native widget failed"); - }; - - expect(() => extension?.nodeCreated?.(node)).toThrow( - "native widget failed" - ); - expect(logger.warn).toHaveBeenCalledWith( - "Could not configure Load Mask Batch native controls: native widget failed", - expect.any(Error) - ); - }); - - it("uses native cross-renderer controls and hides internal upload state", () => { - const { imageWidget, node, uploadWidget } = createNode(["only.png"]); - configureMaskBatchNode(node, createExecutionEvents()); - - const replace = button(node, "simple_syrup_replace_masks"); - const add = button(node, "simple_syrup_add_masks"); - const selected = button(node, "simple_syrup_selected_mask"); - const remove = button(node, "simple_syrup_remove_mask"); - expect(uploadWidget.label).toBe("choose file to upload"); - expect(imageWidget.options).toMatchObject({ - canvasOnly: true, - hidden: true - }); - expect(uploadWidget.options).toMatchObject({ - canvasOnly: true, - hidden: true - }); - expect(imageWidget.computeSize?.()).toEqual([0, -4]); - expect(imageWidget.hidden).toBe(true); - expect(uploadWidget.computeSize?.()).toEqual([0, -4]); - expect(replace.label).toBe("Replace masks..."); - expect(add.label).toBe("Add masks..."); - expect(selected.label).toBe("selected mask"); - expect(selected.value).toBe("1. only.png"); - expect(selected.options?.values).toEqual(["1. only.png"]); - expect(remove.label).toBe("Remove selected mask"); - expect(add.type).toBe("button"); - expect(add.options).toMatchObject({ serialize: false }); - expect(add.options?.canvasOnly).toBeUndefined(); - expect(remove.disabled).toBe(false); - expect(remove.options?.disabled).toBe(false); - }); - - it("shows an explicit disabled empty state instead of undefined", () => { - const { node } = createNode([]); - - configureMaskBatchNode(node, createExecutionEvents()); - - const selected = button(node, "simple_syrup_selected_mask"); - expect(selected.value).toBe("No masks loaded"); - expect(selected.options?.values).toEqual(["No masks loaded"]); - expect(selected.disabled).toBe(true); - expect(selected.options?.disabled).toBe(true); - expect(button(node, "simple_syrup_remove_mask").disabled).toBe(true); - }); - - it("uses the native multi-value widget as the only selection owner", async () => { - const { imageWidget, node } = createNode([]); - const loadPreview = previewClient(); - configureMaskBatchNode(node, createExecutionEvents(), loadPreview); - loadPreview.mockClear(); - - button(node, "simple_syrup_add_masks").callback?.(); - const uploaded = ["one.png", "two.png", "three.png"]; - selectFiles(imageWidget, uploaded); - - expect(imageWidget.value).toEqual(uploaded); - expect(imageWidget.serializeValue).toBeUndefined(); - expect(node.properties).toEqual({}); - await vi.waitFor(() => { - expect(loadPreview).toHaveBeenCalledWith(uploaded, "alpha"); - }); - }); - - it("accepts direct native list edits and clears without a backend request", async () => { - const { imageWidget, node } = createNode(["one.png", "two.png"]); - const loadPreview = previewClient(); - configureMaskBatchNode(node, createExecutionEvents(), loadPreview); - loadPreview.mockClear(); - - selectFiles(imageWidget, ["two.png"]); - await vi.waitFor(() => { - expect(loadPreview).toHaveBeenCalledWith(["two.png"], "alpha"); - }); - - loadPreview.mockClear(); - selectFiles(imageWidget, []); - - expect(imageWidget.value).toEqual([]); - expect(loadPreview).not.toHaveBeenCalled(); - expect(button(node, "simple_syrup_remove_mask").disabled).toBe(true); - }); - - it("normalizes a serialized scalar from an older workflow", async () => { - const { imageWidget, node } = createNode("saved-mask.png"); - const loadPreview = previewClient(); - configureMaskBatchNode(node, createExecutionEvents(), loadPreview); - loadPreview.mockClear(); - - node.onGraphConfigured?.(); - - expect(imageWidget.value).toEqual(["saved-mask.png"]); - await vi.waitFor(() => { - expect(loadPreview).toHaveBeenCalledWith(["saved-mask.png"], "alpha"); - }); - }); - - it("replaces the ordered list through the explicit native control", async () => { - const { imageWidget, nativeUploadCallback, node } = createNode([ - "old.png" - ]); - const loadPreview = previewClient(); - configureMaskBatchNode(node, createExecutionEvents(), loadPreview); - loadPreview.mockClear(); - - button(node, "simple_syrup_replace_masks").callback?.(); - const replacement = ["right.png", "left.png"]; - selectFiles(imageWidget, replacement); - - expect(nativeUploadCallback).toHaveBeenCalledOnce(); - expect(imageWidget.value).toEqual(replacement); - expect(imageWidget.serializeValue).toBeUndefined(); - expect(node.properties).toEqual({}); - await vi.waitFor(() => { - expect(loadPreview).toHaveBeenCalledWith(replacement, "alpha"); - }); - }); - - it("appends native upload results in order and preserves duplicate paths", async () => { - const { imageWidget, nativeUploadCallback, node } = createNode(); - const loadPreview = previewClient(); - configureMaskBatchNode(node, createExecutionEvents(), loadPreview); - selectFiles(imageWidget, ["one.png", "duplicate.png"]); - loadPreview.mockClear(); - - button(node, "simple_syrup_add_masks").callback?.(); - imageWidget.value = "two.png"; - imageWidget.callback?.("two.png"); - const uploaded = ["two.png", "duplicate.png"]; - selectFiles(imageWidget, uploaded); - - const expected = [ - "one.png", - "duplicate.png", - "two.png", - "duplicate.png" - ]; - expect(nativeUploadCallback).toHaveBeenCalledOnce(); - expect(uploaded).toEqual(["two.png", "duplicate.png"]); - expect(imageWidget.value).toEqual(expected); - expect(imageWidget.serializeValue).toBeUndefined(); - expect(node.properties).toEqual({}); - await vi.waitFor(() => { - expect(loadPreview).toHaveBeenCalledWith(expected, "alpha"); - }); - }); - - it("avoids recursive updates from the Nodes 2.0 reactive multiselect", async () => { - const { imageWidget, node } = createNode(["one.png"]); - let storedValue = imageWidget.value; - Object.defineProperty(imageWidget, "value", { - configurable: true, - get: () => storedValue, - set: (value: unknown) => { - storedValue = value; - imageWidget.callback?.(value); - } - }); - const loadPreview = previewClient(); - configureMaskBatchNode(node, createExecutionEvents(), loadPreview); - loadPreview.mockClear(); - - button(node, "simple_syrup_add_masks").callback?.(); - const uploaded = ["two.png", "three.png"]; - imageWidget.value = uploaded; - imageWidget.callback?.([...uploaded]); - - expect(imageWidget.value).toEqual([ - "one.png", - "two.png", - "three.png" - ]); - await vi.waitFor(() => { - expect(loadPreview).toHaveBeenCalledTimes(1); - }); - }); - - it("cancelled Add cannot affect later replace, paste, or drop selections", () => { - const { imageWidget, node, onDragDrop, pasteFiles, uploadWidget } = - createNode(); - configureMaskBatchNode(node, createExecutionEvents()); - selectFiles(imageWidget, ["existing.png"]); - - button(node, "simple_syrup_add_masks").callback?.(); - uploadWidget.callback?.(); - selectFiles(imageWidget, ["replace.png"]); - expect(imageWidget.value).toEqual(["replace.png"]); - - button(node, "simple_syrup_add_masks").callback?.(); - node.pasteFiles(); - selectFiles(imageWidget, ["paste.png"]); - expect(imageWidget.value).toEqual(["paste.png"]); - expect(pasteFiles).toHaveBeenCalledOnce(); - - button(node, "simple_syrup_add_masks").callback?.(); - void node.onDragDrop(); - selectFiles(imageWidget, ["drop.png"]); - expect(imageWidget.value).toEqual(["drop.png"]); - expect(onDragDrop).toHaveBeenCalledOnce(); - }); - - it("removes the active gallery position and never deletes by filename", async () => { - const { imageWidget, node, onWidgetChanged } = createNode(); - const loadPreview = previewClient(); - configureMaskBatchNode(node, createExecutionEvents(), loadPreview); - selectFiles(imageWidget, ["duplicate.png", "middle.png", "duplicate.png"]); - loadPreview.mockClear(); - onWidgetChanged.mockClear(); - node.imageIndex = 2; - - button(node, "simple_syrup_remove_mask").callback?.(); - - const remaining = ["duplicate.png", "middle.png"]; - expect(imageWidget.value).toEqual(remaining); - expect(node.imageIndex).toBe(1); - expect(onWidgetChanged).toHaveBeenCalledWith( - "image", - remaining, - ["duplicate.png", "middle.png", "duplicate.png"], - imageWidget - ); - await vi.waitFor(() => { - expect(loadPreview).toHaveBeenCalledWith(remaining, "alpha"); - }); - }); - - it("removes the native list selection and permits clearing the final mask", () => { - const { imageWidget, node, onWidgetChanged } = createNode(); - configureMaskBatchNode(node, createExecutionEvents()); - selectFiles(imageWidget, ["one.png", "two.png"]); - const selected = button(node, "simple_syrup_selected_mask"); - const remove = button(node, "simple_syrup_remove_mask"); - - selected.callback?.("2. two.png"); - remove.callback?.(); - expect(imageWidget.value).toEqual(["one.png"]); - expect(selected.value).toBe("1. one.png"); - expect(remove.disabled).toBe(false); - - remove.callback?.(); - expect(imageWidget.value).toEqual([]); - expect(selected.value).toBe("No masks loaded"); - expect(selected.options?.values).toEqual(["No masks loaded"]); - expect(selected.disabled).toBe(true); - expect(remove.disabled).toBe(true); - expect(onWidgetChanged).toHaveBeenCalledTimes(2); - }); - - it("clears stale native preview state when the final mask is removed", () => { - const { imageWidget, node } = createNode(["one.png"]); - const loadPreview = previewClient(); - const app = { - nodeOutputs: { "7": PREVIEW_OUTPUT }, - registerExtension: vi.fn(), - ui: { settings: { addSetting: vi.fn() } } - } as unknown as ComfyApp; - configureMaskBatchNode( - node, - createExecutionEvents(), - loadPreview, - console, - app - ); - node.imgs = [{}]; - node.images = PREVIEW_OUTPUT.images; - app.nodeOutputs = { "7": PREVIEW_OUTPUT }; - - selectFiles(imageWidget, []); - - expect(node.imgs).toBeUndefined(); - expect(node.images).toEqual([]); - expect(node.imageIndex).toBeNull(); - expect(app.nodeOutputs).toEqual({}); - }); - - it("restores a persisted batch and refreshes the selected channel", async () => { - const { channelWidget, imageWidget, node } = createNode([ - "one.png", - "two.png" - ]); - const loadPreview = previewClient(); - configureMaskBatchNode(node, createExecutionEvents(), loadPreview); - loadPreview.mockClear(); - channelWidget.value = "blue"; - - node.onGraphConfigured?.(); - - expect(imageWidget.value).toEqual(["one.png", "two.png"]); - await vi.waitFor(() => { - expect(loadPreview).toHaveBeenCalledWith( - ["one.png", "two.png"], - "blue" - ); - }); - }); - - it("restores native paste and drop handlers before native node cleanup", () => { - const { node, onDragDrop, originalOnRemoved, pasteFiles } = createNode(); - configureMaskBatchNode(node, createExecutionEvents()); - - node.onRemoved(); - - expect(originalOnRemoved).toHaveBeenCalledOnce(); - expect(node.pasteFiles).toBe(pasteFiles); - expect(node.onDragDrop).toBe(onDragDrop); - }); - - it("does not modify unrelated nodes", () => { - const { node, uploadWidget } = createNode(); - node.constructor.comfyClass = "LoadImageMask"; - - configureMaskBatchNode(node, createExecutionEvents()); - - expect(uploadWidget.label).toBe("choose file to upload"); - expect(node.widgets).toHaveLength(3); - }); -}); diff --git a/web/tests/maskedTextureRenderer.test.ts b/web/tests/maskedTextureRenderer.test.ts new file mode 100644 index 0000000..9f11d00 --- /dev/null +++ b/web/tests/maskedTextureRenderer.test.ts @@ -0,0 +1,61 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MaskedTextureRenderer } from "../src/maskedTextureRenderer"; + +describe("MaskedTextureRenderer", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("keeps source texture inside the mask and transparency outside it", () => { + const events: string[] = []; + const drawImage = vi.fn(() => { + events.push("draw"); + }); + let compositeOperation = "source-over"; + const context = { + drawImage, + restore: vi.fn(), + save: vi.fn() + } as unknown as CanvasRenderingContext2D; + Object.defineProperty(context, "globalCompositeOperation", { + get: () => compositeOperation, + set: (value: string) => { + compositeOperation = value; + events.push(value); + } + }); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context); + const image = document.createElement("img"); + const mask = document.createElement("canvas"); + + const canvas = new MaskedTextureRenderer(image).render( + { x: 10, y: 20, width: 200, height: 100 }, + mask, + { width: 160, height: 160 } + ); + + expect({ width: canvas.width, height: canvas.height }).toEqual({ + width: 160, + height: 80 + }); + expect(events).toEqual(["draw", "destination-in", "draw"]); + expect(drawImage).toHaveBeenNthCalledWith( + 1, + image, + 10, + 20, + 200, + 100, + 0, + 0, + 160, + 80 + ); + expect(drawImage).toHaveBeenNthCalledWith(2, mask, 0, 0, 160, 80); + }); +}); diff --git a/web/tests/nativeNodePreview.test.ts b/web/tests/nativeNodePreview.test.ts new file mode 100644 index 0000000..90cc48e --- /dev/null +++ b/web/tests/nativeNodePreview.test.ts @@ -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 + +import { describe, expect, it, vi } from "vitest"; + +import { NativeNodePreview } from "../src/nativeNodePreview"; +import type { ComfyApi, ComfyApp } from "../src/types"; + +describe("NativeNodePreview", () => { + it("publishes execution-shaped output through Comfy's native event path", () => { + const app = { nodeOutputs: {} } as ComfyApp; + const events = new EventTarget(); + const api = events as ComfyApi; + const listener = vi.fn(); + events.addEventListener("executed", (event: Event) => { + listener((event as CustomEvent).detail); + }); + const preview = new NativeNodePreview(app, api, { id: 42 }); + const output = { + images: [{ filename: "one.png", subfolder: "", type: "input" as const }] + }; + + preview.publish(output); + + expect(app.nodeOutputs?.["42"]).toBe(output); + expect(listener).toHaveBeenCalledWith({ + node: "42", + display_node: "42", + output + }); + }); + + it("clears the same native output surface", () => { + const app = { nodeOutputs: {} } as ComfyApp; + const api = new EventTarget() as ComfyApi; + const preview = new NativeNodePreview(app, api, { id: 42 }); + + preview.clear(); + + expect(app.nodeOutputs?.["42"]).toEqual({ images: [], animated: [] }); + }); + + it("notifies renderer adapters after native output changes", () => { + const app = { nodeOutputs: {} } as ComfyApp; + const api = new EventTarget() as ComfyApi; + const preview = new NativeNodePreview(app, api, { id: 42 }); + const listener = vi.fn(); + const unsubscribe = preview.subscribe(listener); + + preview.clear(); + unsubscribe(); + preview.clear(); + + expect(listener).toHaveBeenCalledOnce(); + }); + +}); diff --git a/web/tests/nativePreviewNavigator.test.ts b/web/tests/nativePreviewNavigator.test.ts new file mode 100644 index 0000000..c9de2a3 --- /dev/null +++ b/web/tests/nativePreviewNavigator.test.ts @@ -0,0 +1,199 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { NativePreviewNavigator } from "../src/nativePreviewNavigator"; +import type { ComfyApp } from "../src/types"; +import { createFakeComfyApp } from "./testUtils"; + +describe("NativePreviewNavigator", () => { + afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); + }); + + it("clicks the matching Nodes 2.0 native gallery item", () => { + const app = previewApp(); + const node = { id: 7, imageIndex: null as number | null }; + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + const first = nativeImageButton("first.png"); + const second = nativeImageButton("second.png"); + root.append(first.button, second.button); + document.body.append(root); + const clicked = vi.fn(); + second.button.addEventListener("click", clicked); + + const navigator = new NativePreviewNavigator(app, node); + navigator.inspect(1); + + expect(clicked).toHaveBeenCalledOnce(); + expect(node.imageIndex).toBeNull(); + navigator.dispose(); + }); + + it("waits for a restored Nodes 2.0 gallery before opening detail", async () => { + const app = previewApp(); + const node = { id: 7, imageIndex: null as number | null }; + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + document.body.append(root); + const navigator = new NativePreviewNavigator(app, node); + const second = nativeImageButton("second.png"); + const clicked = vi.fn(); + second.button.addEventListener("click", clicked); + + navigator.inspect(1); + root.append(nativeImageButton("first.png").button, second.button); + + await vi.waitFor(() => { + expect(clicked).toHaveBeenCalledOnce(); + }); + navigator.dispose(); + }); + + it("uses Comfy's native return-to-grid control in Nodes 2.0", () => { + const app = previewApp(); + const node = { id: 7, imageIndex: null as number | null }; + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + const grid = document.createElement("button"); + grid.setAttribute("aria-label", "Grid view"); + const clicked = vi.fn(); + grid.addEventListener("click", clicked); + root.append(grid); + document.body.append(root); + const navigator = new NativePreviewNavigator(app, node); + + navigator.showGrid(); + + expect(clicked).toHaveBeenCalledOnce(); + navigator.dispose(); + }); + + it("uses the Nodes 1.0 image index when no DOM gallery is mounted", async () => { + const app = createFakeComfyApp(); + const dirty = vi.fn(); + const node = { + id: 7, + imageIndex: null as number | null, + imageRects: [[0, 0, 10, 10]], + imgs: [document.createElement("img"), document.createElement("img")], + graph: { setDirtyCanvas: dirty } + }; + + const navigator = new NativePreviewNavigator(app, node); + navigator.inspect(1); + + await vi.waitFor(() => { + expect(node.imageIndex).toBe(1); + expect(node.imageRects).toBeUndefined(); + expect(dirty).toHaveBeenCalledWith(true, true); + }); + navigator.dispose(); + }); + + it("uses Nodes 1.0 native pointer selection when the canvas is available", async () => { + const app = createFakeComfyApp(); + app.canvas = { graph_mouse: [12, 34] } as unknown as NonNullable< + ComfyApp["canvas"] + >; + const dirty = vi.fn<(foreground: boolean, background: boolean) => void>(); + const node: { + id: number; + imageIndex: number | null; + imgs: HTMLImageElement[]; + pointerDown?: { index: number | null; pos: [number, number] }; + graph: { + setDirtyCanvas: (foreground: boolean, background: boolean) => void; + }; + } = { + id: 7, + imageIndex: null, + imgs: [document.createElement("img"), document.createElement("img")], + graph: { setDirtyCanvas: dirty } + }; + + const navigator = new NativePreviewNavigator(app, node); + navigator.inspect(1); + + await vi.waitFor(() => { + expect(node.pointerDown).toEqual({ index: 1, pos: [12, 34] }); + expect(node.imageIndex).toBe(1); + expect(dirty).toHaveBeenCalledWith(true, true); + }); + navigator.dispose(); + }); + + it("waits for Nodes 1.0 images before selecting native detail", async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "requestAnimationFrame", + (callback: FrameRequestCallback): number => + window.setTimeout(() => { + callback(performance.now()); + }, 16) + ); + vi.stubGlobal("cancelAnimationFrame", (handle: number): void => { + window.clearTimeout(handle); + }); + const app = createFakeComfyApp(); + const node: { + id: number; + imageIndex: number | null; + imgs?: HTMLImageElement[]; + graph: { + setDirtyCanvas: ReturnType< + typeof vi.fn<(foreground: boolean, background: boolean) => void> + >; + }; + } = { + id: 7, + imageIndex: null as number | null, + graph: { + setDirtyCanvas: + vi.fn<(foreground: boolean, background: boolean) => void>() + } + }; + const navigator = new NativePreviewNavigator(app, node); + + navigator.inspect(1); + await vi.advanceTimersByTimeAsync(16); + + expect(node.imageIndex).toBeNull(); + + node.imgs = [document.createElement("img"), document.createElement("img")]; + await vi.advanceTimersByTimeAsync(16); + + expect(node.imageIndex).toBe(1); + navigator.dispose(); + }); + +}); + +function previewApp(): ComfyApp { + const app = createFakeComfyApp(); + app.nodeOutputs = { + "7": { + images: [reference("first.png"), reference("second.png")] + } + }; + return app; +} + +function reference(filename: string) { + return { filename, subfolder: "", type: "temp" as const }; +} + +function nativeImageButton(filename: string): { + button: HTMLButtonElement; + image: HTMLImageElement; +} { + const button = document.createElement("button"); + const image = document.createElement("img"); + image.src = `/view?filename=${filename}&subfolder=&type=temp`; + button.append(image); + return { button, image }; +} diff --git a/web/tests/orderedMediaNode.test.ts b/web/tests/orderedMediaNode.test.ts new file mode 100644 index 0000000..06d7b1b --- /dev/null +++ b/web/tests/orderedMediaNode.test.ts @@ -0,0 +1,649 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + configureOrderedMediaNode, + registerOrderedMediaNode +} from "../src/orderedMediaNode"; +import type { + ComfyApi, + ComfyApp, + ComfyExtension, + ComfyImageResult, + ComfyNodeExecutionOutput +} from "../src/types"; + +const CONFIG = { + nodeId: "SimpleSyrup.LoadImageList", + labels: { + singular: "image", + plural: "images", + replace: "Replace images...", + add: "Add images..." + }, + preview: (files: string[]) => + Promise.resolve({ + images: references(...files), + animated: files.map(() => false) + }) +}; + +const activeNodes: Array<{ onRemoved: () => unknown }> = []; + +afterEach(() => { + for (const node of activeNodes.splice(0)) node.onRemoved(); + document.body.replaceChildren(); +}); + +describe("ordered-media node integration", () => { + it.each(["Nodes 1.0", "Nodes 2.0"])( + "publishes through Comfy's native preview under %s", + async () => { + const fixture = createFixture(["one.png"]); + configureOrderedMediaNode( + fixture.node, + fixture.app, + fixture.api, + CONFIG + ); + + expect(fixture.imageWidget.hidden).toBe(true); + expect(fixture.uploadWidget.hidden).toBe(true); + expect(fixture.imageWidget.computeSize?.()).toEqual([0, -4]); + expect( + fixture.node.widgets.some((candidate) => + [ + "simple_syrup_selected_media", + "simple_syrup_move_earlier", + "simple_syrup_move_later", + "simple_syrup_remove_media" + ].includes(candidate.name) + ) + ).toBe(false); + expect(widget(fixture, "simple_syrup_replace_media").label).toBe( + "Replace images..." + ); + expect(widget(fixture, "simple_syrup_add_media").label).toBe( + "Add images..." + ); + expect("addDOMWidget" in fixture.node).toBe(false); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("one.png") + ); + }); + expect(fixture.executed).toHaveBeenCalledWith( + expect.objectContaining({ node: "7", display_node: "7" }) + ); + } + ); + + it("appends native multi-upload results and preserves duplicates", async () => { + const fixture = configured(["one.png", "same.png"]); + + widget(fixture, "simple_syrup_add_media").callback?.(); + fixture.imageWidget.value = "two.png"; + fixture.imageWidget.callback?.("two.png"); + fixture.imageWidget.value = ["two.png", "same.png"]; + fixture.imageWidget.callback?.(["two.png", "same.png"]); + + expect(fixture.nativeUpload).toHaveBeenCalledOnce(); + expect(fixture.imageWidget.value).toEqual([ + "one.png", + "same.png", + "two.png", + "same.png" + ]); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("one.png", "same.png", "two.png", "same.png") + ); + }); + }); + + it("treats pasted and dropped files as append operations", () => { + const fixture = configured(["existing.png"]); + + fixture.node.onDragDrop(); + selectFiles(fixture, ["dropped-a.png", "dropped-b.png"]); + expect(fixture.imageWidget.value).toEqual([ + "existing.png", + "dropped-a.png", + "dropped-b.png" + ]); + + fixture.node.pasteFiles(); + selectFiles(fixture, ["pasted.png"]); + expect(fixture.imageWidget.value).toEqual([ + "existing.png", + "dropped-a.png", + "dropped-b.png", + "pasted.png" + ]); + }); + + it("attaches actions when a multi-image native gallery mounts later", async () => { + const fixture = configured(["one.png", "two.png"]); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toHaveLength(2); + }); + expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(0); + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + root.append( + nativePreviewButton("one.png", 0), + nativePreviewButton("two.png", 90) + ); + + document.body.append(root); + + await vi.waitFor(() => { + expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(2); + }); + }); + + it("moves and removes exact positions through thumbnail actions", async () => { + const fixture = configured(["same.png", "middle.png", "same.png"]); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toHaveLength(3); + }); + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + root.append( + nativePreviewImage("same.png", 0), + nativePreviewImage("middle.png", 90), + nativePreviewImage("same.png", 180) + ); + document.body.append(root); + await vi.waitFor(() => { + expect(document.querySelectorAll("[data-ss-media-move-earlier]")).toHaveLength(3); + }); + document + .querySelectorAll("[data-ss-media-move-earlier]")[2] + ?.click(); + + expect(fixture.imageWidget.value).toEqual([ + "same.png", + "same.png", + "middle.png" + ]); + expect(visiblePreviewFiles(root)).toEqual([ + "same.png", + "same.png", + "middle.png" + ]); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("same.png", "same.png", "middle.png") + ); + }); + expect(fixture.onWidgetChanged).toHaveBeenCalledWith( + "image", + ["same.png", "same.png", "middle.png"], + ["same.png", "middle.png", "same.png"], + fixture.imageWidget + ); + + document + .querySelectorAll("[data-ss-media-remove]")[1] + ?.click(); + expect(fixture.imageWidget.value).toEqual(["same.png", "middle.png"]); + expect(visiblePreviewFiles(root)).toEqual(["same.png", "middle.png"]); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("same.png", "middle.png") + ); + }); + }); + + it("keeps exactly the native cells in visual order during a slow rebuild", async () => { + const delayed = deferred(); + let previewCalls = 0; + const config = { + ...CONFIG, + preview: (files: string[]) => { + previewCalls += 1; + return previewCalls === 1 + ? Promise.resolve({ images: references(...files) }) + : delayed.promise; + } + }; + const fixture = createFixture(["one.png", "two.png", "three.png"]); + configureOrderedMediaNode( + fixture.node, + fixture.app, + fixture.api, + config + ); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("one.png", "two.png", "three.png") + ); + }); + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + root.append( + nativePreviewImage("one.png", 0), + nativePreviewImage("two.png", 90), + nativePreviewImage("three.png", 180) + ); + document.body.append(root); + await vi.waitFor(() => { + expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(3); + }); + + document + .querySelectorAll("[data-ss-media-move-later]")[0] + ?.click(); + + expect(fixture.imageWidget.value).toEqual([ + "two.png", + "one.png", + "three.png" + ]); + expect(visiblePreviewFiles(root)).toEqual([ + "two.png", + "one.png", + "three.png" + ]); + expect(root.querySelectorAll("img")).toHaveLength(3); + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("one.png", "two.png", "three.png") + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(visiblePreviewFiles(root)).toEqual([ + "two.png", + "one.png", + "three.png" + ]); + + delayed.resolve({ images: references("two.png", "one.png", "three.png") }); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("two.png", "one.png", "three.png") + ); + }); + expect(visiblePreviewFiles(root)).toEqual([ + "two.png", + "one.png", + "three.png" + ]); + + root.replaceChildren( + nativePreviewImage("two.png", 0), + nativePreviewImage("one.png", 90), + nativePreviewImage("three.png", 180) + ); + expect(visiblePreviewFiles(root)).toEqual([ + "two.png", + "one.png", + "three.png" + ]); + expect(root.querySelectorAll("img")).toHaveLength(3); + }); + + it("keeps the native visual and serialized order when rebuilding fails", async () => { + const logger = { warn: vi.fn() }; + let previewCalls = 0; + const config = { + ...CONFIG, + preview: (files: string[]) => { + previewCalls += 1; + return previewCalls === 1 + ? Promise.resolve({ images: references(...files) }) + : Promise.reject(new Error("preview unavailable")); + } + }; + const fixture = createFixture(["one.png", "two.png"]); + configureOrderedMediaNode( + fixture.node, + fixture.app, + fixture.api, + config, + logger + ); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("one.png", "two.png") + ); + }); + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + root.append( + nativePreviewImage("one.png", 0), + nativePreviewImage("two.png", 90) + ); + document.body.append(root); + await vi.waitFor(() => { + expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(2); + }); + + document + .querySelectorAll("[data-ss-media-move-later]")[0] + ?.click(); + + await vi.waitFor(() => { + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("preview unavailable"), + expect.any(Error) + ); + }); + expect(fixture.imageWidget.value).toEqual(["two.png", "one.png"]); + expect(visiblePreviewFiles(root)).toEqual(["two.png", "one.png"]); + expect(root.querySelectorAll("img")).toHaveLength(2); + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("one.png", "two.png") + ); + }); + + it("preserves native thumbnail inspection clicks in Nodes 2.0", async () => { + const fixture = configured(["one.png", "two.png", "three.png"]); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toHaveLength(3); + }); + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + const images = ["one.png", "two.png", "three.png"].map( + (filename, index) => nativePreviewImage(filename, index * 90) + ); + root.append(...images); + document.body.append(root); + const clicked = vi.fn(); + images[1]?.addEventListener("click", clicked); + + images[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(clicked).toHaveBeenCalledOnce(); + }); + + it("moves Nodes 1.0 native canvas cells through imageRects", async () => { + const fixture = createFixture(["one.png", "two.png"]); + const canvas = document.createElement("canvas"); + mockRect(canvas, 0, 0, 500, 500); + document.body.append(canvas); + fixture.app.canvas = { + canvas, + convertEventToCanvasOffset: (event) => [event.clientX, event.clientY], + convertOffsetToCanvas: (position) => [...position] + }; + Object.assign(fixture.node, { + pos: [10, 100], + imageIndex: null, + imageRects: [ + [0, 50, 80, 80], + [80, 50, 80, 80] + ], + imgs: [nativePreviewImage("one.png", 0), nativePreviewImage("two.png", 90)] + }); + configureOrderedMediaNode(fixture.node, fixture.app, fixture.api, CONFIG); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toHaveLength(2); + }); + await vi.waitFor(() => { + expect(document.querySelectorAll("[data-ss-media-move-later]")).toHaveLength(2); + }); + document + .querySelectorAll("[data-ss-media-move-later]")[0] + ?.click(); + + expect(fixture.imageWidget.value).toEqual(["two.png", "one.png"]); + expect(imageFiles(fixture.node.imgs)).toEqual(["two.png", "one.png"]); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("two.png", "one.png") + ); + }); + }); + + it("normalizes scalar workflows and survives Nodes 2.0 reactive assignments", async () => { + const fixture = configured("saved.png", true); + + fixture.node.onGraphConfigured?.(); + + expect(fixture.imageWidget.value).toEqual(["saved.png"]); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("saved.png") + ); + }); + }); + + it("restores native handlers and clears preview on node removal", () => { + const fixture = createFixture(["one.png"]); + const paste = fixture.node.pasteFiles; + const drop = fixture.node.onDragDrop; + configureOrderedMediaNode( + fixture.node, + fixture.app, + fixture.api, + CONFIG + ); + + fixture.node.onRemoved(); + + expect(fixture.node.pasteFiles).toBe(paste); + expect(fixture.node.onDragDrop).toBe(drop); + expect(fixture.originalRemoved).toHaveBeenCalledOnce(); + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual([]); + }); + + it("registers a guarded extension and ignores unrelated nodes", async () => { + const fixture = createFixture([]); + let extension: ComfyExtension | undefined; + fixture.app.registerExtension = (value: ComfyExtension) => { + extension = value; + }; + registerOrderedMediaNode( + fixture.app, + fixture.api, + "test.extension", + CONFIG + ); + fixture.node.constructor.comfyClass = "Other.Node"; + + await extension?.nodeCreated?.(fixture.node); + + expect(extension?.name).toBe("test.extension"); + expect(fixture.node.addWidget).not.toHaveBeenCalled(); + }); +}); + +interface TestWidget { + name: string; + value: unknown; + type?: string; + label?: string; + hidden?: boolean; + disabled?: boolean; + callback?: (value?: unknown) => void; + computeSize?: (width?: number) => [number, number]; + options?: { + canvasOnly?: boolean; + serialize?: boolean; + tooltip?: string; + hidden?: boolean; + }; +} + +function configured(value: unknown, reactive = false) { + const fixture = createFixture(value, reactive); + configureOrderedMediaNode( + fixture.node, + fixture.app, + fixture.api, + CONFIG + ); + return fixture; +} + +function createFixture(value: unknown, reactive = false) { + const nativeUpload = vi.fn(); + const imageWidget: TestWidget = { + name: "image", + value, + type: "combo", + options: { canvasOnly: true } + }; + if (reactive) { + let stored = value; + Object.defineProperty(imageWidget, "value", { + configurable: true, + get: () => stored, + set: (next: unknown) => { + stored = next; + imageWidget.callback?.(next); + } + }); + } + const uploadWidget: TestWidget = { + name: "upload", + value: "image", + type: "button", + callback: nativeUpload, + options: { serialize: false, canvasOnly: true } + }; + const widgets = [imageWidget, uploadWidget]; + const originalRemoved = vi.fn(); + const onWidgetChanged = vi.fn(); + const node = { + constructor: { comfyClass: "SimpleSyrup.LoadImageList" }, + id: 7, + widgets, + imgs: undefined as HTMLImageElement[] | undefined, + graph: { setDirtyCanvas: vi.fn() }, + pasteFiles: vi.fn(() => true) as (...args: unknown[]) => unknown, + onDragDrop: vi.fn(() => true) as (...args: unknown[]) => unknown, + onRemoved: originalRemoved as (...args: unknown[]) => unknown, + onGraphConfigured: undefined as ((...args: unknown[]) => unknown) | undefined, + onWidgetChanged, + addWidget: vi.fn( + ( + type: "button" | "combo", + name: string, + widgetValue: string, + callback: (value?: unknown) => void, + options?: TestWidget["options"] + ): TestWidget => { + const created: TestWidget = { + type, + name, + value: widgetValue, + callback + }; + if (options) created.options = options; + widgets.push(created); + return created; + } + ) + }; + activeNodes.push(node); + const app = { + nodeOutputs: {}, + ui: { settings: { addSetting: vi.fn() } }, + registerExtension: vi.fn() + } as unknown as ComfyApp; + const events = new EventTarget(); + const api = events as ComfyApi; + const executed = vi.fn(); + events.addEventListener("executed", (event: Event) => { + executed((event as CustomEvent).detail); + }); + return { + api, + app, + executed, + imageWidget, + nativeUpload, + node, + onWidgetChanged, + originalRemoved, + uploadWidget + }; +} + +function nativePreviewImage( + filename: string, + left: number +): HTMLImageElement { + const image = document.createElement("img"); + image.src = `/api/view?filename=${filename}&subfolder=&type=input&preview=webp`; + Object.defineProperties(image, { + complete: { configurable: true, value: true }, + naturalWidth: { configurable: true, value: 80 } + }); + mockRect(image, left, 0, 80, 80); + return image; +} + +function nativePreviewButton(filename: string, left: number): HTMLButtonElement { + const button = document.createElement("button"); + button.append(nativePreviewImage(filename, left)); + mockRect(button, left, 0, 80, 80); + return button; +} + +function visiblePreviewFiles(root: HTMLElement): string[] { + return Array.from(root.querySelectorAll("img")) + .filter((image) => image.style.display !== "none") + .map((image) => new URL(image.src).searchParams.get("filename") ?? ""); +} + +function imageFiles(images: HTMLImageElement[] | undefined): string[] { + return (images ?? []).map( + (image) => new URL(image.src).searchParams.get("filename") ?? "" + ); +} + +function mockRect( + element: Element, + left: number, + top: number, + width: number, + height: number +): void { + element.getBoundingClientRect = () => new DOMRect(left, top, width, height); +} + +function widget( + fixture: ReturnType, + name: string +): TestWidget { + const found = fixture.node.widgets.find((candidate) => candidate.name === name); + if (!found) throw new Error(`Missing test widget ${name}.`); + return found; +} + +function selectFiles( + fixture: ReturnType, + files: string[] +): void { + fixture.imageWidget.value = files; + fixture.imageWidget.callback?.(files); +} + +function references(...filenames: string[]): ComfyImageResult[] { + return filenames.map((filename) => ({ + filename, + subfolder: "", + type: "input" + })); +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve: (value) => { + if (!resolvePromise) throw new Error("Deferred promise is unavailable."); + resolvePromise(value); + } + }; +} diff --git a/web/tests/orderedMediaPreview.test.ts b/web/tests/orderedMediaPreview.test.ts new file mode 100644 index 0000000..ce4506e --- /dev/null +++ b/web/tests/orderedMediaPreview.test.ts @@ -0,0 +1,127 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from "vitest"; + +import { OrderedMediaPreviewController } from "../src/orderedMediaPreview"; +import type { ComfyNodeExecutionOutput, Logger } from "../src/types"; + +const OUTPUT: ComfyNodeExecutionOutput = { + images: [{ filename: "mask.png", subfolder: "", type: "temp" }], + animated: [false] +}; + +describe("OrderedMediaPreviewController", () => { + it("renders empty selections without making a preview request", () => { + const target = fakeTarget(); + const client = vi.fn(); + const controller = new OrderedMediaPreviewController(target, "mask loader"); + + controller.refresh([], client); + + expect(target.clear).toHaveBeenCalledOnce(); + expect(client).not.toHaveBeenCalled(); + }); + + it("loads and publishes exact ordered references", async () => { + const target = fakeTarget(); + const controller = new OrderedMediaPreviewController(target, "mask loader"); + + controller.refresh(["mask.png"], () => Promise.resolve(OUTPUT)); + + await vi.waitFor(() => { + expect(target.publish).toHaveBeenCalledWith(OUTPUT); + }); + }); + + it("discards stale responses after a newer channel request", async () => { + const first = deferred(); + const second = deferred(); + const target = fakeTarget(); + const controller = new OrderedMediaPreviewController(target, "mask loader"); + const latest: ComfyNodeExecutionOutput = { + images: [{ filename: "blue.png", subfolder: "", type: "temp" }] + }; + + controller.refresh(["mask.png"], () => first.promise); + controller.refresh(["mask.png"], () => second.promise); + second.resolve(latest); + await vi.waitFor(() => { + expect(target.publish).toHaveBeenCalledWith(latest); + }); + first.resolve(OUTPUT); + await Promise.resolve(); + + expect(target.publish).toHaveBeenCalledTimes(1); + }); + + it("keeps failures visible and invalidates pending work on dispose", async () => { + const error = new Error("preview failed"); + const logger: Logger = { warn: vi.fn() }; + const target = fakeTarget(); + const controller = new OrderedMediaPreviewController(target, "mask loader", logger); + + controller.refresh(["mask.png"], () => Promise.reject(error)); + await vi.waitFor(() => { + expect(target.clear).toHaveBeenCalledOnce(); + }); + expect(logger.warn).toHaveBeenCalledWith( + "Could not refresh mask loader preview: preview failed", + error + ); + + const pending = deferred(); + controller.refresh(["mask.png"], () => pending.promise); + controller.dispose(); + pending.resolve(OUTPUT); + await Promise.resolve(); + expect(target.publish).not.toHaveBeenCalled(); + }); + + it("preserves the last native preview when a replacement fails", async () => { + const error = new Error("replacement failed"); + const logger: Logger = { warn: vi.fn() }; + const target = fakeTarget(); + const controller = new OrderedMediaPreviewController( + target, + "image loader", + logger + ); + controller.refresh(["mask.png"], () => Promise.resolve(OUTPUT)); + await vi.waitFor(() => { + expect(target.publish).toHaveBeenCalledWith(OUTPUT); + }); + + controller.refresh(["other.png"], () => Promise.reject(error)); + await vi.waitFor(() => { + expect(logger.warn).toHaveBeenCalledWith( + "Could not refresh image loader preview: replacement failed", + error + ); + }); + + expect(target.clear).not.toHaveBeenCalled(); + }); +}); + +function fakeTarget() { + return { + publish: vi.fn(), + clear: vi.fn() + }; +} + +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve(value: T) { + if (!resolvePromise) throw new Error("Deferred promise was not initialized."); + resolvePromise(value); + } + }; +} diff --git a/web/tests/orderedMediaPreviewActions.test.ts b/web/tests/orderedMediaPreviewActions.test.ts new file mode 100644 index 0000000..e770a2c --- /dev/null +++ b/web/tests/orderedMediaPreviewActions.test.ts @@ -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 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { NativeNodePreview } from "../src/nativeNodePreview"; +import { OrderedMediaPreviewActions } from "../src/orderedMediaPreviewActions"; +import type { ComfyApi, ComfyApp, ComfyImageResult } from "../src/types"; + +const adapters: OrderedMediaPreviewActions[] = []; + +afterEach(() => { + for (const adapter of adapters.splice(0)) adapter.dispose(); + document.body.replaceChildren(); +}); + +describe("OrderedMediaPreviewActions", () => { + it("discovers Nodes 2.0 thumbnails by output reference", async () => { + const fixture = actionFixture(["one.png", "two.png"]); + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + const unrelated = document.createElement("img"); + unrelated.src = "/icon.png"; + root.append(unrelated, previewImage("one.png", 0), previewImage("two.png", 100)); + document.body.append(root); + + await vi.waitFor(() => { + expect(document.querySelectorAll("[data-ss-media-move-earlier]")).toHaveLength(2); + }); + document + .querySelectorAll("[data-ss-media-move-earlier]")[1] + ?.click(); + document + .querySelectorAll("[data-ss-media-move-later]")[0] + ?.click(); + document + .querySelectorAll("[data-ss-media-remove]")[1] + ?.click(); + + expect(fixture.moveEarlier).toHaveBeenCalledWith(1); + expect(fixture.moveLater).toHaveBeenCalledWith(0); + expect(fixture.remove).toHaveBeenCalledWith(1); + }); + + it("positions controls from Nodes 1.0 imageRects", async () => { + const canvas = document.createElement("canvas"); + mockRect(canvas, 10, 20, 500, 500); + const fixture = actionFixture(["one.png", "two.png"], { + pos: [100, 200], + imageIndex: null, + imageRects: [ + [0, 30, 80, 80], + [80, 30, 80, 80] + ] + }); + fixture.app.canvas = { + canvas, + convertEventToCanvasOffset: (event) => [ + event.clientX - 10, + event.clientY - 20 + ], + convertOffsetToCanvas: (position) => [...position] + }; + document.body.append(canvas); + + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2); + }); + const first = document.querySelector( + ".ss-native-preview-affordance" + ); + expect(first?.style.left).toBe("110px"); + expect(first?.style.top).toBe("250px"); + expect(first?.style.width).toBe("80px"); + }); + + it("restores controls when native detail view returns to the grid", async () => { + const node = { id: 7, imageIndex: null as number | null }; + const fixture = actionFixture(["one.png", "two.png"], node); + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + root.append(previewImage("one.png", 0), previewImage("two.png", 100)); + document.body.append(root); + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2); + }); + + fixture.node.imageIndex = 0; + document.dispatchEvent(new MouseEvent("click")); + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(0); + }); + + fixture.node.imageIndex = null; + document.dispatchEvent(new MouseEvent("click")); + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2); + }); + }); + + it("hides actions when Comfy output cannot map to every file", async () => { + const fixture = actionFixture(["one.png", "two.png"]); + fixture.app.nodeOutputs = { "7": { images: references("one.png") } }; + document.dispatchEvent(new MouseEvent("click")); + + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(0); + }); + }); +}); + +function actionFixture( + files: string[], + nodeOverrides: Record = {} +) { + const app: ComfyApp = { + nodeOutputs: { + "7": { images: references(...files), animated: files.map(() => false) } + }, + ui: { settings: { addSetting: vi.fn() } }, + registerExtension: vi.fn() + }; + const api = new EventTarget() as ComfyApi; + const node = { id: 7, ...nodeOverrides } as { + id: number; + imageIndex?: number | null; + }; + const preview = new NativeNodePreview(app, api, node); + const moveEarlier = vi.fn(); + const moveLater = vi.fn(); + const remove = vi.fn(); + const adapter = new OrderedMediaPreviewActions({ + app, + node, + preview, + itemLabel: "image", + getFiles: () => [...files], + moveEarlier, + moveLater, + remove + }); + adapters.push(adapter); + return { adapter, app, moveEarlier, moveLater, node, remove }; +} + +function previewImage(filename: string, left: number): HTMLImageElement { + const image = document.createElement("img"); + image.src = `/api/view?filename=${filename}&subfolder=&type=input&preview=webp`; + mockRect(image, left, 0, 80, 80); + return image; +} + +function mockRect( + element: Element, + left: number, + top: number, + width: number, + height: number +): void { + element.getBoundingClientRect = () => new DOMRect(left, top, width, height); +} + +function references(...filenames: string[]): ComfyImageResult[] { + return filenames.map((filename) => ({ + filename, + subfolder: "", + type: "input" + })); +} diff --git a/web/tests/orderedMediaPreviewAffordances.test.ts b/web/tests/orderedMediaPreviewAffordances.test.ts new file mode 100644 index 0000000..25b42b6 --- /dev/null +++ b/web/tests/orderedMediaPreviewAffordances.test.ts @@ -0,0 +1,122 @@ +// SimpleSyrup - workflow-focused ComfyUI extensions for image generation +// Copyright (C) 2026 Artificial Sweetener and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { OrderedMediaPreviewAffordances } from "../src/orderedMediaPreviewAffordances"; + +let affordances: OrderedMediaPreviewAffordances | undefined; + +afterEach(() => { + affordances?.dispose(); + affordances = undefined; + document.body.replaceChildren(); +}); + +describe("OrderedMediaPreviewAffordances", () => { + it("positions native-icon list actions over every native slot", async () => { + const moveEarlier = vi.fn(); + const moveLater = vi.fn(); + const remove = vi.fn(); + affordances = new OrderedMediaPreviewAffordances({ + itemLabel: "mask", + getSlots: () => [ + { left: 10, top: 20, width: 100, height: 120 }, + { left: 120, top: 20, width: 80, height: 120 } + ], + moveEarlier, + moveLater, + remove + }); + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2); + }); + + const earlierButtons = document.querySelectorAll( + "[data-ss-media-move-earlier]" + ); + const laterButtons = document.querySelectorAll( + "[data-ss-media-move-later]" + ); + const removeButtons = document.querySelectorAll( + "[data-ss-media-remove]" + ); + expect(earlierButtons[0]?.getAttribute("aria-label")).toBe( + "Move mask 1 earlier" + ); + expect(laterButtons[1]?.getAttribute("aria-label")).toBe( + "Move mask 2 later" + ); + expect(removeButtons[1]?.getAttribute("aria-label")).toBe("Remove mask 2"); + expect(earlierButtons[0]?.disabled).toBe(true); + expect(laterButtons[1]?.disabled).toBe(true); + expect(earlierButtons[1]?.disabled).toBe(false); + expect(laterButtons[0]?.disabled).toBe(false); + expect(document.querySelectorAll(".pi-arrow-left")).toHaveLength(2); + expect(document.querySelectorAll(".pi-arrow-right")).toHaveLength(2); + expect(document.querySelectorAll(".pi-times")).toHaveLength(2); + expect(earlierButtons[0]?.parentElement?.style.left).toBe("10px"); + + earlierButtons[1]?.click(); + laterButtons[0]?.click(); + removeButtons[1]?.click(); + expect(moveEarlier).toHaveBeenCalledWith(1); + expect(moveLater).toHaveBeenCalledWith(0); + expect(remove).toHaveBeenCalledWith(1); + }); + + it("removes stale controls when the native preview shrinks", async () => { + let count = 2; + affordances = new OrderedMediaPreviewAffordances({ + itemLabel: "image", + getSlots: () => + Array.from({ length: count }, (_, index) => ({ + left: index * 100, + top: 0, + width: 90, + height: 90 + })), + moveEarlier: vi.fn(), + moveLater: vi.fn(), + remove: vi.fn() + }); + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2); + }); + + count = 1; + affordances.refresh(); + + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1); + }); + }); + + it("disappears in detail view and returns after native grid navigation", async () => { + let visible = true; + affordances = new OrderedMediaPreviewAffordances({ + itemLabel: "image", + getSlots: () => + visible ? [{ left: 10, top: 20, width: 100, height: 120 }] : [], + moveEarlier: vi.fn(), + moveLater: vi.fn(), + remove: vi.fn() + }); + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1); + }); + + visible = false; + document.dispatchEvent(new MouseEvent("click")); + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(0); + }); + + visible = true; + document.dispatchEvent(new MouseEvent("click")); + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1); + }); + }); +}); diff --git a/web/tests/orderedMediaPreviewTransaction.test.ts b/web/tests/orderedMediaPreviewTransaction.test.ts new file mode 100644 index 0000000..02044de --- /dev/null +++ b/web/tests/orderedMediaPreviewTransaction.test.ts @@ -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 + +import { describe, expect, it, vi } from "vitest"; + +import { + OrderedMediaPreviewTransaction, + type OrderedMediaPreviewItem +} from "../src/orderedMediaPreviewTransaction"; +import type { NativePreviewSlot } from "../src/orderedMediaPreviewAffordances"; + +describe("OrderedMediaPreviewTransaction", () => { + it("reorders the captured native surface without creating DOM elements", async () => { + let ready = false; + let rendered = items("one.png", "two.png"); + const apply = vi.fn((next: OrderedMediaPreviewItem[]) => { + rendered = [...next]; + }); + const release = vi.fn(); + const transaction = new OrderedMediaPreviewTransaction({ + captureSurface: () => ({ + items: rendered, + slots: [() => slot(0), () => slot(100)], + apply, + release + }), + authoritativeReady: () => ready, + stateChanged: vi.fn() + }); + const childCount = document.body.childElementCount; + + expect(transaction.move(0, 1)).toBe(true); + + expect(rendered.map((item) => file(item.sourceUrl))).toEqual([ + "two.png", + "one.png" + ]); + expect(document.body.childElementCount).toBe(childCount); + transaction.authoritativePublished(); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(release).not.toHaveBeenCalled(); + ready = true; + await vi.waitFor(() => { + expect(release).toHaveBeenCalledOnce(); + }); + transaction.dispose(); + }); + + it("compacts removals through the same native surface", () => { + let rendered = items("one.png", "two.png", "three.png"); + const transaction = new OrderedMediaPreviewTransaction({ + captureSurface: () => ({ + items: rendered, + slots: [() => slot(0), () => slot(100), () => slot(200)], + apply: (next) => { + rendered = [...next]; + }, + release: vi.fn() + }), + authoritativeReady: () => false, + stateChanged: vi.fn() + }); + + expect(transaction.remove(1)).toBe(true); + + expect(rendered.map((item) => file(item.sourceUrl))).toEqual([ + "one.png", + "three.png" + ]); + expect(transaction.activeSlots()).toEqual([slot(0), slot(100)]); + transaction.dispose(); + }); +}); + +function items(...files: string[]): OrderedMediaPreviewItem[] { + return files.map((name) => { + const image = document.createElement("img"); + image.src = `http://localhost/${name}`; + return { sourceUrl: image.src, image }; + }); +} + +function slot(left: number): NativePreviewSlot { + return { left, top: 20, width: 80, height: 90 }; +} + +function file(sourceUrl: string): string { + return new URL(sourceUrl).pathname.slice(1); +} diff --git a/web/tests/orderedMediaSelection.test.ts b/web/tests/orderedMediaSelection.test.ts new file mode 100644 index 0000000..392543e --- /dev/null +++ b/web/tests/orderedMediaSelection.test.ts @@ -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 + +import { describe, expect, it } from "vitest"; + +import { + normalizeMediaFiles, + OrderedMediaSelection +} from "../src/orderedMediaSelection"; + +describe("OrderedMediaSelection", () => { + it("normalizes scalar and invalid persisted values", () => { + expect(normalizeMediaFiles("one.png")).toEqual(["one.png"]); + expect(normalizeMediaFiles(["one.png", "", 4, "two.png"])).toEqual([ + "one.png", + "two.png" + ]); + }); + + it("preserves duplicate positions through append, move, and remove", () => { + const selection = new OrderedMediaSelection(["same.png", "middle.png"]); + + expect(selection.append(["same.png"])).toEqual([ + "same.png", + "middle.png", + "same.png" + ]); + expect(selection.move(2, 0)).toEqual([ + "same.png", + "same.png", + "middle.png" + ]); + expect(selection.remove(1)).toEqual(["same.png", "middle.png"]); + }); + + it("ignores invalid positional edits and returns defensive snapshots", () => { + const selection = new OrderedMediaSelection(["one.png", "two.png"]); + const snapshot = selection.move(-1, 7); + snapshot.splice(0, 2); + + expect(selection.snapshot()).toEqual(["one.png", "two.png"]); + }); +}); diff --git a/web/tests/segPreviewInspector.test.ts b/web/tests/segPreviewInspector.test.ts index 8f9ed7b..7ecccb7 100644 --- a/web/tests/segPreviewInspector.test.ts +++ b/web/tests/segPreviewInspector.test.ts @@ -14,10 +14,17 @@ describe("SegPreviewInspector", () => { vi.unstubAllGlobals(); }); - it("shares pinned selection between overlay and grid views", async () => { + it("hands overlay selection to Comfy's native preview navigator", async () => { installCanvasMock(); const loadImage = vi.fn(() => Promise.resolve(document.createElement("img"))); - const inspector = new SegPreviewInspector((path) => path, loadImage); + const inspectRegion = vi.fn<(index: number) => void>(); + const changeMode = vi.fn<(mode: "overlay" | "grid") => void>(); + const inspector = new SegPreviewInspector( + inspectRegion, + changeMode, + (path) => path, + loadImage + ); const prepared = await inspector.prepare(previewDocument()); prepared.commit(); const highlight = requiredElement( @@ -34,17 +41,44 @@ describe("SegPreviewInspector", () => { ); expect(inspector.element.textContent).toContain("subject"); highlight.click(); - buttonNamed(inspector.element, "Grid").click(); - const card = requiredElement( - inspector.element, - ".ss-segs-preview__card" + expect(changeMode).not.toHaveBeenCalled(); + expect(inspectRegion).toHaveBeenCalledWith(0); + expect( + inspector.element.querySelector(".ss-segs-preview__canvas-stack") + ).toBeNull(); + }); + + it("switches to the native grid without implementing another gallery", async () => { + installCanvasMock(); + const loadImage = vi.fn(() => Promise.resolve(document.createElement("img"))); + const changeMode = vi.fn<(mode: "overlay" | "grid") => void>(); + const inspector = new SegPreviewInspector( + () => undefined, + changeMode, + (path) => path, + loadImage ); - if (!(card instanceof HTMLButtonElement)) { - throw new Error("Missing region card."); - } - expect(card.getAttribute("aria-selected")).toBe("true"); - expect(card.dataset.highlighted).toBe("true"); + const prepared = await inspector.prepare(previewDocument()); + prepared.commit(); + + expect(inspector.element.querySelector(".ss-segs-preview__grid")).toBeNull(); + expect( + inspector.element.querySelector(".ss-segs-preview__inspection") + ).toBeNull(); + const buttons = Array.from(inspector.element.querySelectorAll("button")); + expect(buttons.map((button) => button.textContent)).toEqual([ + "Overlay", + "Grid" + ]); + + buttons[1]?.click(); + + expect(changeMode).toHaveBeenCalledWith("grid"); + expect(inspector.element.dataset.mode).toBe("grid"); + expect(inspector.element.querySelector(".ss-segs-preview__body")?.childNodes) + .toHaveLength(0); + expect(inspector.preferredHeight()).toBe(38); }); it("switches to an actionable error without throwing from the node lifecycle", () => { @@ -84,14 +118,6 @@ function previewDocument(): SegPreviewDocument { }; } -function buttonNamed(root: ParentNode, name: string): HTMLButtonElement { - const button = Array.from(root.querySelectorAll("button")).find( - (candidate) => candidate.textContent === name - ); - if (!button) throw new Error(`Missing '${name}' button.`); - return button; -} - function requiredElement(root: ParentNode, selector: string): Element { const element = root.querySelector(selector); if (!element) throw new Error(`Missing '${selector}' element.`); diff --git a/web/tests/segPreviewNativeSurface.test.ts b/web/tests/segPreviewNativeSurface.test.ts new file mode 100644 index 0000000..c0fd50f --- /dev/null +++ b/web/tests/segPreviewNativeSurface.test.ts @@ -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 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SegPreviewNativeSurface } from "../src/segPreviewNativeSurface"; +import type { ComfyApi } from "../src/types"; +import { createFakeComfyApp } from "./testUtils"; + +describe("SegPreviewNativeSurface", () => { + afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); + }); + + it("keeps overlay and native grid mutually exclusive", () => { + const app = createFakeComfyApp(); + const apiTarget = new EventTarget(); + const api = apiTarget as ComfyApi; + const node = previewNode(); + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + document.body.append(root); + const surface = new SegPreviewNativeSurface(app, api, node); + const output = executionOutput(); + + surface.update(output); + + expect(app.nodeOutputs?.["7"]?.images).toEqual([]); + expect(node.imgs).toEqual([]); + expect(node.widgets[0]?.hidden).toBe(true); + + surface.showGrid(); + + expect(app.nodeOutputs?.["7"]?.images).toEqual(output.images); + expect(node.imageIndex).toBeNull(); + expect(node.widgets[0]?.hidden).toBe(false); + + surface.showOverlay(); + + expect(app.nodeOutputs?.["7"]?.images).toEqual([]); + expect(node.imgs).toEqual([]); + expect(node.widgets[0]?.hidden).toBe(true); + surface.dispose(); + }); + + it("opens native detail and returns to the same native grid", async () => { + const app = createFakeComfyApp(); + const apiTarget = new EventTarget(); + const api = apiTarget as ComfyApi; + const dispatchEvent = vi.spyOn(api, "dispatchEvent"); + const node = previewNode(); + const surface = new SegPreviewNativeSurface(app, api, node); + const output = executionOutput(); + apiTarget.addEventListener("executed", () => { + node.imgs = output.images.map(loadedImage); + }); + surface.update(output); + + surface.inspect(1); + + expect(app.nodeOutputs?.["7"]?.images).toEqual(output.images); + await vi.waitFor(() => { + expect(node.imageIndex).toBe(1); + }); + surface.update(app.nodeOutputs?.["7"]); + expect(node.imageIndex).toBe(1); + + surface.showGrid(); + + expect(node.imageIndex).toBeNull(); + expect(node.images).toBeUndefined(); + expect(app.nodeOutputs?.["7"]?.images).not.toBe(output.images); + expect(dispatchEvent).toHaveBeenCalledOnce(); + expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true); + surface.dispose(); + }); + + it("preserves loaded Nodes 1.0 images while the canvas preview is hidden", async () => { + const app = createFakeComfyApp(); + const api = new EventTarget() as ComfyApi; + const dispatchEvent = vi.spyOn(api, "dispatchEvent"); + const node = previewNode(); + const output = executionOutput(); + const loadedImages = output.images.map(loadedImage); + node.imgs = loadedImages; + app.nodeOutputs = { "7": output }; + const surface = new SegPreviewNativeSurface(app, api, node); + + surface.update(output); + + expect(node.widgets[0]?.hidden).toBe(true); + expect(node.imgs).toBe(loadedImages); + expect(app.nodeOutputs["7"]?.images).toEqual(output.images); + expect(dispatchEvent).not.toHaveBeenCalled(); + + surface.inspect(1); + + await vi.waitFor(() => { + expect(node.imageIndex).toBe(1); + }); + expect(node.imgs).toBe(loadedImages); + expect(dispatchEvent).not.toHaveBeenCalled(); + surface.dispose(); + }); +}); + +function previewNode() { + return { + id: 7, + imageIndex: null as number | null, + imageRects: [[0, 0, 10, 10]] as readonly unknown[], + images: [reference("stale.png")], + imgs: [document.createElement("img")], + widgets: [ + { + name: "$$canvas-image-preview", + hidden: false, + options: { hidden: false } + } + ], + graph: { setDirtyCanvas: vi.fn() } + }; +} + +function executionOutput() { + const image = { filename: "asset.png", subfolder: "", type: "temp" as const }; + return { + images: [reference("first.png"), reference("second.png")], + simple_syrup_segs_preview: [ + { + version: 1, + source: { width: 4, height: 4 }, + preview: { width: 4, height: 4, image }, + atlas: { width: 2, height: 1, image }, + regions: [region(0), region(1)] + } + ] + }; +} + +function reference(filename: string) { + return { filename, subfolder: "", type: "temp" as const }; +} + +function loadedImage(reference: ReturnType["images"][number]) { + const image = document.createElement("img"); + image.src = `/view?filename=${reference.filename}&subfolder=${reference.subfolder}&type=${reference.type}`; + return image; +} + +function region(index: number) { + return { + id: `seg-${String(index + 1).padStart(4, "0")}`, + index, + label: `region ${String(index + 1)}`, + confidence: 0.9, + area: 8, + color: "#f24236", + crop: { x: index * 2, y: 0, width: 2, height: 4 }, + atlas: { x: index, y: 0, width: 1, height: 1 } + }; +} diff --git a/web/tests/segPreviewNode.test.ts b/web/tests/segPreviewNode.test.ts index 8e4d96a..29f9686 100644 --- a/web/tests/segPreviewNode.test.ts +++ b/web/tests/segPreviewNode.test.ts @@ -16,6 +16,7 @@ describe("Simple Preview SEGS node integration", () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + document.body.replaceChildren(); }); it.each(["Nodes 1.0", "Nodes 2.0"])( @@ -29,25 +30,61 @@ describe("Simple Preview SEGS node integration", () => { registerSimplePreviewSEGS(app, api, loadImage); const extension = requiredExtension(app.extensions); const node = fakeNode(renderer === "Nodes 2.0" ? 22 : 11); + if (renderer === "Nodes 2.0") { + const nativeRoot = document.createElement("section"); + nativeRoot.dataset.nodeId = String(node.id); + document.body.append(nativeRoot); + } await extension.nodeCreated?.(node); expect(node.addDOMWidget).toHaveBeenCalledOnce(); const widgetOptions = node.addDOMWidget.mock.calls[0]?.[3]; expect(widgetOptions).toEqual({ serialize: false, canvasOnly: false }); - expect(node.setSize).toHaveBeenCalledWith([420, 520]); + const previewWidget = node.addDOMWidget.mock.results[0]?.value as + | FakeDomWidget + | undefined; + expect(previewWidget?.computeLayoutSize).toBeUndefined(); + expect(node.setSize).toHaveBeenCalledWith([420, 420]); + const output = executionOutput(); + app.nodeOutputs ??= {}; + app.nodeOutputs[String(node.id)] = output; if (renderer === "Nodes 1.0") { - node.onExecuted?.(executionOutput()); + node.onExecuted?.(output); } else { await extension.onNodeOutputsUpdated?.({ - [String(node.id)]: executionOutput() + [String(node.id)]: output }); } const root = node.previewRoot; await vi.waitFor(() => { expect(root.querySelector(".ss-segs-preview__canvas-stack")).not.toBeNull(); }); + expect(app.nodeOutputs[String(node.id)]?.images).toEqual( + renderer === "Nodes 1.0" + ? [{ filename: "region.png", subfolder: "", type: "temp" }] + : [] + ); + expect(root.querySelector(".ss-segs-preview__grid")).toBeNull(); + + modeButton(root, "Grid").click(); + + expect(node.arrange).toHaveBeenCalled(); + expect(app.nodeOutputs[String(node.id)]?.images).toEqual([ + { filename: "region.png", subfolder: "", type: "temp" } + ]); + expect(root.querySelector(".ss-segs-preview__canvas-stack")).toBeNull(); + expect(root.querySelector(".ss-segs-preview__grid")).toBeNull(); + + modeButton(root, "Overlay").click(); + + expect(app.nodeOutputs[String(node.id)]?.images).toEqual( + renderer === "Nodes 1.0" + ? [{ filename: "region.png", subfolder: "", type: "temp" }] + : [] + ); + expect(root.querySelector(".ss-segs-preview__canvas-stack")).not.toBeNull(); node.onRemoved?.(); expect(root.querySelector(".ss-segs-preview__canvas-stack")).toBeNull(); @@ -58,6 +95,10 @@ describe("Simple Preview SEGS node integration", () => { interface FakeDomWidget { serialize?: boolean; computeSize?: (width?: number) => [number, number]; + computeLayoutSize?: () => { + minHeight: number; + minWidth: number; + }; options: { serialize?: boolean; canvasOnly?: boolean }; } @@ -68,6 +109,7 @@ interface FakePreviewNode { previewRoot: HTMLElement; computeSize: ReturnType [number, number]>>; setSize: ReturnType void>>; + arrange: ReturnType; addDOMWidget: ReturnType< typeof vi.fn< ( @@ -81,6 +123,10 @@ interface FakePreviewNode { onExecuted: ((output: unknown) => void) | undefined; onGraphConfigured: ((...args: unknown[]) => unknown) | undefined; onRemoved: ((...args: unknown[]) => unknown) | undefined; + imageIndex: number | null; + imageRects?: readonly unknown[]; + imgs?: HTMLImageElement[]; + graph: { setDirtyCanvas: ReturnType }; } function fakeNode(id: number): FakePreviewNode { @@ -92,6 +138,7 @@ function fakeNode(id: number): FakePreviewNode { previewRoot, computeSize: vi.fn((): [number, number] => [300, 240]), setSize: vi.fn<(size: [number, number]) => void>(), + arrange: vi.fn(), addDOMWidget: vi.fn( ( _name: string, @@ -102,12 +149,18 @@ function fakeNode(id: number): FakePreviewNode { void options; previewRoot.replaceWith(element); node.previewRoot = element; - return { options: {} }; + return { + options: {}, + computeLayoutSize: () => ({ minHeight: 50, minWidth: 0 }) + }; } ), onExecuted: undefined as ((output: unknown) => void) | undefined, onGraphConfigured: undefined as ((...args: unknown[]) => unknown) | undefined, - onRemoved: undefined as ((...args: unknown[]) => unknown) | undefined + onRemoved: undefined as ((...args: unknown[]) => unknown) | undefined, + imageIndex: null, + imgs: [], + graph: { setDirtyCanvas: vi.fn() } }; return node; } @@ -117,19 +170,40 @@ function executionOutput(): ComfyNodeExecutionOutput & { } { const image = { filename: "asset.png", subfolder: "", type: "temp" as const }; return { - images: [], + images: [ + { filename: "region.png", subfolder: "", type: "temp" as const } + ], simple_syrup_segs_preview: [ { version: 1, source: { width: 4, height: 4 }, preview: { width: 4, height: 4, image }, atlas: { width: 1, height: 1, image }, - regions: [] + regions: [ + { + id: "seg-0001", + index: 0, + label: "subject", + confidence: 0.9, + area: 16, + color: "#f24236", + crop: { x: 0, y: 0, width: 4, height: 4 }, + atlas: { x: 0, y: 0, width: 1, height: 1 } + } + ] } ] }; } +function modeButton(root: ParentNode, label: string): HTMLButtonElement { + const button = Array.from(root.querySelectorAll("button")).find( + (candidate) => candidate.textContent === label + ); + if (!button) throw new Error(`Missing ${label} mode button.`); + return button; +} + function requiredExtension( extensions: ReturnType["extensions"] ): ComfyExtension { diff --git a/web/tests/segPreviewTypes.test.ts b/web/tests/segPreviewTypes.test.ts index fa859d1..89a4f51 100644 --- a/web/tests/segPreviewTypes.test.ts +++ b/web/tests/segPreviewTypes.test.ts @@ -4,10 +4,8 @@ import { describe, expect, it } from "vitest"; -import { - parseSegPreviewDocument, - previewAssetUrl -} from "../src/segPreviewTypes"; +import { comfyImageUrl } from "../src/comfyImageUrl"; +import { parseSegPreviewDocument } from "../src/segPreviewTypes"; describe("SEG preview transport", () => { it("validates execution payloads and uses the latest mapped document", () => { @@ -25,7 +23,7 @@ describe("SEG preview transport", () => { }); it("builds an encoded view URL through Comfy's base-path resolver", () => { - const url = previewAssetUrl( + const url = comfyImageUrl( { filename: "one two.png", subfolder: "a/b", type: "temp" }, (path) => `/comfy${path}` ); From be348aeedae32f09b8769e558aba117c2a37ef4b Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Tue, 4 Aug 2026 21:41:32 -0400 Subject: [PATCH 31/40] fix(media): stabilize native ordered preview controls --- web/dist/simple-syrup.js | 541 +++++++++++++++--- web/src/nativeNodePreview.ts | 79 ++- web/src/nativePreviewLifecycle.ts | 3 +- web/src/orderedMediaNode.ts | 25 +- web/src/orderedMediaPreviewActions.ts | 447 +++++++++++++-- web/src/orderedMediaPreviewAffordances.ts | 142 ++++- web/src/segPreviewNativeSurface.ts | 5 +- web/src/types.ts | 13 + web/tests/nativeNodePreview.test.ts | 111 +++- web/tests/orderedMediaNode.test.ts | 52 +- web/tests/orderedMediaPreviewActions.test.ts | 273 ++++++++- .../orderedMediaPreviewAffordances.test.ts | 147 ++++- 12 files changed, 1645 insertions(+), 193 deletions(-) diff --git a/web/dist/simple-syrup.js b/web/dist/simple-syrup.js index fdd2338..5dceb46 100644 --- a/web/dist/simple-syrup.js +++ b/web/dist/simple-syrup.js @@ -561,15 +561,19 @@ var NativeNodePreview = class { publishListeners = /* @__PURE__ */ new Set(); /** Publish media as an execution-shaped output for both node renderers. */ publish(output) { - const nodeId = this.nodeId(); - if (!nodeId) return; + const localNodeId = this.localNodeId(); + if (!localNodeId) return; + const outputLocator = this.outputLocator(localNodeId); this.app.nodeOutputs ??= {}; - this.app.nodeOutputs[nodeId] = output; - this.api.dispatchEvent( - new CustomEvent("executed", { - detail: { node: nodeId, display_node: nodeId, output } - }) - ); + this.app.nodeOutputs[localNodeId] = output; + this.app.nodeOutputs[outputLocator] = output; + for (const executionId of this.executionIds(localNodeId)) { + this.api.dispatchEvent( + new CustomEvent("executed", { + detail: { node: executionId, display_node: executionId, output } + }) + ); + } for (const listener of this.publishListeners) listener(); } /** Notify renderer adapters after native output changes. */ @@ -581,11 +585,51 @@ var NativeNodePreview = class { clear() { this.publish({ images: [], animated: [] }); } - nodeId() { + localNodeId() { if (this.node.id === void 0) return void 0; return String(this.node.id); } + /** Match the output key selected by Comfy's Nodes 2.0 subgraph renderer. */ + outputLocator(localNodeId) { + const graphId = this.node.graph?.id; + const isSubgraph = this.node.graph && this.node.graph !== this.app.rootGraph; + return isSubgraph && graphId !== void 0 ? `${String(graphId)}:${localNodeId}` : localNodeId; + } + /** Resolve instance paths because Comfy events consume execution IDs, not graph locators. */ + executionIds(localNodeId) { + const graph = this.node.graph; + const rootGraph = this.app.rootGraph; + if (!graph || !rootGraph || graph === rootGraph || graph.isRootGraph) { + return [localNodeId]; + } + const parentPaths = findGraphInstancePaths(rootGraph, graph); + return parentPaths.length > 0 ? parentPaths.map((path) => `${path}:${localNodeId}`) : []; + } }; +function findGraphInstancePaths(root, target, visited = /* @__PURE__ */ new Set()) { + if (visited.has(root)) return []; + const nextVisited = new Set(visited); + nextVisited.add(root); + const paths = []; + for (const node of root.nodes ?? []) { + const nodeId = graphNodeId(node); + if (!nodeId || !node.subgraph) continue; + if (node.subgraph === target) paths.push(nodeId); + for (const nestedPath of findGraphInstancePaths( + node.subgraph, + target, + nextVisited + )) { + paths.push(`${nodeId}:${nestedPath}`); + } + } + return paths; +} +function graphNodeId(node) { + if (node.id === void 0) return void 0; + const identity = String(node.id); + return identity.length > 0 ? identity : void 0; +} // web/src/orderedMediaPreview.ts var OrderedMediaPreviewController = class { @@ -663,7 +707,7 @@ function ensureObserver() { childList: true, subtree: true, attributes: true, - attributeFilter: ["src"] + attributeFilter: ["aria-current", "src"] }); } @@ -678,15 +722,19 @@ var OrderedMediaPreviewAffordances = class { window.addEventListener("resize", this.requestRefresh, true); document.addEventListener("click", this.requestRefresh, true); document.addEventListener("keydown", this.requestRefresh, true); + document.addEventListener("pointerdown", this.suspendViewportControls, true); document.addEventListener("pointermove", this.requestRefresh, true); - document.addEventListener("pointerup", this.requestRefresh, true); + document.addEventListener("pointerup", this.resumeViewportControls, true); + document.addEventListener("pointercancel", this.resumeViewportControls, true); document.addEventListener("wheel", this.requestRefresh, true); this.refresh(); } options; elements = []; + positionedContainers = /* @__PURE__ */ new Map(); animationFrame = null; remainingSyncFrames = 0; + viewportControlsSuspended = false; /** Recheck native layout across several frames after Comfy rerenders output. */ refresh() { this.remainingSyncFrames = Math.max(this.remainingSyncFrames, 4); @@ -697,8 +745,10 @@ var OrderedMediaPreviewAffordances = class { window.removeEventListener("resize", this.requestRefresh, true); document.removeEventListener("click", this.requestRefresh, true); document.removeEventListener("keydown", this.requestRefresh, true); + document.removeEventListener("pointerdown", this.suspendViewportControls, true); document.removeEventListener("pointermove", this.requestRefresh, true); - document.removeEventListener("pointerup", this.requestRefresh, true); + document.removeEventListener("pointerup", this.resumeViewportControls, true); + document.removeEventListener("pointercancel", this.resumeViewportControls, true); document.removeEventListener("wheel", this.requestRefresh, true); if (this.animationFrame !== null) { cancelAnimationFrame(this.animationFrame); @@ -706,11 +756,24 @@ var OrderedMediaPreviewAffordances = class { } for (const element of this.elements) element.root.remove(); this.elements = []; + this.releasePositionedContainers(/* @__PURE__ */ new Set()); } requestRefresh = () => { this.remainingSyncFrames = Math.max(this.remainingSyncFrames, 2); this.scheduleSync(); }; + suspendViewportControls = (event) => { + if (!(event.target instanceof HTMLCanvasElement)) return; + this.viewportControlsSuspended = true; + for (const elements of this.elements) { + if (elements.root.parentElement === document.body) elements.root.hidden = true; + } + }; + resumeViewportControls = () => { + if (!this.viewportControlsSuspended) return; + this.viewportControlsSuspended = false; + this.refresh(); + }; scheduleSync() { if (this.animationFrame !== null) return; this.animationFrame = requestAnimationFrame(() => { @@ -722,43 +785,88 @@ var OrderedMediaPreviewAffordances = class { } sync() { const slots = this.options.getSlots(); + const activeContainers = /* @__PURE__ */ new Set(); this.resizeElements(slots.length); for (const [index, elements] of this.elements.entries()) { - const slot = slots[index]; - if (!slot || slot.width <= 0 || slot.height <= 0) { + const actionSlot = slots[index]; + const slot = actionSlot?.bounds; + if (!actionSlot || !slot || slot.width <= 0 || slot.height <= 0 || !actionSlot.container && this.viewportControlsSuspended) { elements.root.hidden = true; continue; } + const itemIndex = actionSlot.itemIndex; elements.root.hidden = false; - const stripHeight = Math.max(18, Math.min(26, slot.height * 0.16)); + const position = this.mount(elements.root, actionSlot, activeContainers); + const stripHeight = Math.max(18, Math.min(26, position.height * 0.16)); Object.assign(elements.root.style, { - left: `${String(slot.left)}px`, - top: `${String(slot.top)}px`, - width: `${String(slot.width)}px`, + left: `${String(position.left)}px`, + top: `${String(position.top)}px`, + width: `${String(position.width)}px`, height: `${String(stripHeight)}px` }); - elements.root.dataset.ssMediaIndex = String(index); - elements.earlier.dataset.ssMediaIndex = String(index); - elements.later.dataset.ssMediaIndex = String(index); - elements.remove.dataset.ssMediaIndex = String(index); - setActionAvailability(elements.earlier, index > 0); + elements.root.dataset.ssMediaIndex = String(itemIndex); + elements.earlier.dataset.ssMediaIndex = String(itemIndex); + elements.later.dataset.ssMediaIndex = String(itemIndex); + elements.remove.dataset.ssMediaIndex = String(itemIndex); + setActionAvailability(elements.earlier, itemIndex > 0); setActionAvailability( elements.later, - index < this.elements.length - 1 + itemIndex < this.options.getItemCount() - 1 ); elements.earlier.setAttribute( "aria-label", - `Move ${this.options.itemLabel} ${String(index + 1)} earlier` + `Move ${this.options.itemLabel} ${String(itemIndex + 1)} earlier` ); elements.later.setAttribute( "aria-label", - `Move ${this.options.itemLabel} ${String(index + 1)} later` + `Move ${this.options.itemLabel} ${String(itemIndex + 1)} later` ); elements.remove.setAttribute( "aria-label", - `Remove ${this.options.itemLabel} ${String(index + 1)}` + `Remove ${this.options.itemLabel} ${String(itemIndex + 1)}` ); } + this.releasePositionedContainers(activeContainers); + } + /** Mount one control strip in the preview surface that owns its geometry. */ + mount(root, actionSlot, activeContainers) { + const container = actionSlot.container; + if (!container) { + if (root.parentElement !== document.body) document.body.append(root); + root.style.position = "fixed"; + root.style.zIndex = "2"; + return actionSlot.bounds; + } + activeContainers.add(container); + this.positionContainer(container); + if (root.parentElement !== container) container.append(root); + root.style.position = "absolute"; + root.style.zIndex = "1"; + const containerRect = container.getBoundingClientRect(); + const scaleX = containerScale(containerRect.width, container.offsetWidth); + const scaleY = containerScale(containerRect.height, container.offsetHeight); + return { + left: (actionSlot.bounds.left - containerRect.left) / scaleX - container.clientLeft + container.scrollLeft, + top: (actionSlot.bounds.top - containerRect.top) / scaleY - container.clientTop + container.scrollTop, + width: actionSlot.bounds.width / scaleX, + height: actionSlot.bounds.height / scaleY + }; + } + /** Establish a local containing block without overriding authored positioning. */ + positionContainer(container) { + if (this.positionedContainers.has(container)) return; + const position = getComputedStyle(container).position; + if (position !== "" && position !== "static") return; + this.positionedContainers.set(container, container.style.position); + container.style.position = "relative"; + } + /** Restore preview surfaces that no longer contain loader controls. */ + releasePositionedContainers(activeContainers) { + for (const [container, originalPosition] of this.positionedContainers) { + if (activeContainers.has(container)) continue; + container.style.position = originalPosition; + this.positionedContainers.delete(container); + } } resizeElements(count) { while (this.elements.length > count) this.elements.pop()?.root.remove(); @@ -771,14 +879,12 @@ var OrderedMediaPreviewAffordances = class { root.className = "ss-native-preview-affordance"; Object.assign(root.style, { position: "fixed", - zIndex: "9990", + zIndex: "2", display: "flex", alignItems: "stretch", pointerEvents: "none", overflow: "hidden", - borderRadius: "4px 4px 0 0", - background: "rgba(20, 20, 20, 0.72)", - boxShadow: "inset 0 -1px 0 rgba(255, 255, 255, 0.16)" + background: "rgba(20, 20, 20, 0.72)" }); const earlier = controlButton("pi-arrow-left", "Move earlier"); earlier.setAttribute(MEDIA_MOVE_EARLIER_ATTRIBUTE, "true"); @@ -847,6 +953,11 @@ function stopControlPointerEvent(event) { event.preventDefault(); event.stopPropagation(); } +function containerScale(renderedSize, localSize) { + if (renderedSize <= 0 || localSize <= 0) return 1; + const scale = renderedSize / localSize; + return Number.isFinite(scale) && scale > 0 ? scale : 1; +} // web/src/orderedMediaPreviewTransaction.ts var OrderedMediaPreviewTransaction = class { @@ -951,6 +1062,9 @@ function loadedImagesMatchReferences(images, references) { } // web/src/orderedMediaPreviewActions.ts +var CUBE_FACE_PROJECTION_SYMBOL = /* @__PURE__ */ Symbol.for( + "sugarcubes.cube-face-projection.v1" +); var OrderedMediaPreviewActions = class { constructor(options) { this.options = options; @@ -961,25 +1075,39 @@ var OrderedMediaPreviewActions = class { this.affordances.refresh(); } }); + const moveEarlier = (index) => { + const destination = index - 1; + this.transaction.move(index, destination); + this.followMovedDetail(index, destination); + options.moveEarlier(index); + }; + const moveLater = (index) => { + const destination = index + 1; + this.transaction.move(index, destination); + this.followMovedDetail(index, destination); + options.moveLater(index); + }; + const remove = (index) => { + const removedDetail = this.selectedItemIndex() === index; + this.transaction.remove(index); + options.remove(index); + this.followRemovedDetail(index, removedDetail); + }; + const actionOptions = { + getItemCount: () => this.itemCount(), + moveEarlier, + moveLater, + remove + }; this.affordances = new OrderedMediaPreviewAffordances({ itemLabel: options.itemLabel, - getSlots: () => this.nativeSlots(), - moveEarlier: (index) => { - this.transaction.move(index, index - 1); - options.moveEarlier(index); - }, - moveLater: (index) => { - this.transaction.move(index, index + 1); - options.moveLater(index); - }, - remove: (index) => { - this.transaction.remove(index); - options.remove(index); - } + getSlots: () => this.nativeActionSlots(), + ...actionOptions }); this.unsubscribePreview = options.preview.subscribe(() => { this.affordances.refresh(); this.transaction.authoritativePublished(); + this.restorePendingDetail(); }); this.unsubscribeLifecycle = subscribeNativePreviewLifecycle(() => { this.affordances.refresh(); @@ -990,12 +1118,19 @@ var OrderedMediaPreviewActions = class { transaction; unsubscribePreview; unsubscribeLifecycle; + lastCanvasPreviewRect = null; + pendingDetailIndex = null; + detailRestoreFrame = null; /** Remove layout listeners and every loader-owned overlay control. */ dispose() { this.unsubscribePreview(); this.unsubscribeLifecycle(); this.transaction.dispose(); this.affordances.dispose(); + if (this.detailRestoreFrame !== null) { + cancelAnimationFrame(this.detailRestoreFrame); + this.detailRestoreFrame = null; + } } itemCount() { const files = this.options.getFiles(); @@ -1031,36 +1166,35 @@ var OrderedMediaPreviewActions = class { ) ?? null; } canvasSlots() { - const canvasApi = this.options.app.canvas; - const nodePosition = this.options.node.pos; const imageRects = this.options.node.imageRects; - if (!canvasApi || !nodePosition || !imageRects) return []; - const canvasRect = canvasApi.canvas.getBoundingClientRect(); - return imageRects.map(([x, y, width, height]) => { - const topLeft = canvasApi.convertOffsetToCanvas([ - nodePosition[0] + x, - nodePosition[1] + y - ]); - const bottomRight = canvasApi.convertOffsetToCanvas([ - nodePosition[0] + x + width, - nodePosition[1] + y + height - ]); - return { - left: canvasRect.left + topLeft[0], - top: canvasRect.top + topLeft[1], - width: bottomRight[0] - topLeft[0], - height: bottomRight[1] - topLeft[1] - }; - }); + if (!imageRects) return []; + if (imageRects.length > 0) { + this.lastCanvasPreviewRect = unionImageRects(imageRects); + } + const slots = []; + for (const rect of imageRects) { + const slot = this.canvasLocalSlot(rect); + if (slot) slots.push(slot); + } + return slots; } - nativeSlots() { - if (this.options.node.flags?.collapsed || this.options.node.imageIndex != null) { - return []; + nativeActionSlots() { + if (!this.belongsToActiveWorkflow()) return []; + if (this.options.node.flags?.collapsed) return []; + const selectedIndex = this.selectedItemIndex(); + if (selectedIndex !== null) { + return this.detailActionSlots(selectedIndex); } const activeSlots = this.transaction.activeSlots(); - if (activeSlots) return activeSlots; + const domImages = this.domImages(); + if (activeSlots) { + return indexedSlots( + activeSlots, + this.domPreviewContainer(domImages) ?? this.cubeFaceProjection()?.container ?? null + ); + } if (!this.isGridVisible()) return []; - const domSlots = this.domImages().map((image) => { + const domSlots = domImages.map((image) => { const rect = image.getBoundingClientRect(); return { left: rect.left, @@ -1069,7 +1203,146 @@ var OrderedMediaPreviewActions = class { height: rect.height }; }); - return domSlots.length === this.itemCount() ? domSlots : this.canvasSlots(); + if (domSlots.length === this.itemCount()) { + return indexedSlots(domSlots, this.domPreviewContainer(domImages)); + } + return indexedSlots( + this.canvasSlots(), + this.cubeFaceProjection()?.container ?? null + ); + } + /** Prevent inactive workflow tabs with reused node IDs from claiming active Nodes 2 DOM. */ + belongsToActiveWorkflow() { + const nodeGraph = this.options.node.graph; + const nodeRoot = nodeGraph?._rootGraph ?? nodeGraph; + const activeRoot = this.options.app.rootGraph ?? this.options.app.canvas?.graph; + return !nodeRoot || !activeRoot || nodeRoot === activeRoot; + } + /** Expose one selected-item control strip in native detail mode. */ + detailActionSlots(selectedIndex) { + const domSlot = this.domDetailSlot(selectedIndex); + if (domSlot) return [domSlot]; + const canvasSlot = this.canvasDetailSlot(); + if (!canvasSlot) return []; + const container = this.cubeFaceProjection()?.container; + return [ + container ? { itemIndex: selectedIndex, bounds: canvasSlot, container } : { itemIndex: selectedIndex, bounds: canvasSlot } + ]; + } + /** Locate the selected Nodes 2.0 preview without replacing native UI. */ + domDetailSlot(selectedIndex) { + const root = this.domRoot(); + const reference = this.currentImages()?.[selectedIndex]; + if (!root || !reference) return null; + const expectedKey = comfyImageReferenceKey(reference); + const candidates = Array.from( + root.querySelectorAll("img") + ).filter((image2) => { + const rect = image2.getBoundingClientRect(); + return comfyImageSourceKey(image2.src) === expectedKey && rect.width > 0 && rect.height > 0; + }); + const image = candidates.reduce( + (largest, candidate) => !largest || imageArea(candidate) > imageArea(largest) ? candidate : largest, + null + ); + const previewRegion = root.querySelector( + '[role="region"][aria-label^="Image preview"]' + ); + const previewElement = image ?? previewRegion; + if (!previewElement) return null; + return { + itemIndex: selectedIndex, + bounds: elementSlot(previewElement), + container: previewRegion ?? this.domPreviewContainer(image ? [image] : []) ?? root + }; + } + /** Return the closest native preview surface shared by rendered media. */ + domPreviewContainer(images) { + const root = this.domRoot(); + if (!root || images.length === 0) return null; + const previewRegion = root.querySelector( + '[role="region"][aria-label^="Image preview"]' + ); + if (previewRegion && images.every((image) => previewRegion.contains(image))) { + return previewRegion; + } + let candidate = images[0]?.parentElement ?? null; + while (candidate && candidate !== root) { + if (images.every((image) => candidate?.contains(image) === true)) { + return candidate; + } + candidate = candidate.parentElement; + } + return root; + } + /** Resolve selected preview geometry in node-local canvas coordinates. */ + canvasDetailLocalSlot() { + const previewWidget = this.options.node.widgets?.find( + (widget) => widget.options?.canvasOnly === true && typeof widget.y === "number" && typeof widget.computedHeight === "number" && widget.computedHeight > 0 + ); + const nodeWidth = this.options.node.size?.[0]; + const previewY = previewWidget?.y; + const previewHeight = previewWidget?.computedHeight; + if (typeof nodeWidth !== "number" || nodeWidth <= 0 || typeof previewY !== "number" || typeof previewHeight !== "number") { + if (!this.lastCanvasPreviewRect) return null; + const [left, top, width, height] = this.lastCanvasPreviewRect; + return { left, top, width, height }; + } + return { left: 0, top: previewY, width: nodeWidth, height: previewHeight }; + } + /** Locate the selected Nodes 1.0 preview from its canvas widget geometry. */ + canvasDetailSlot() { + const localSlot = this.canvasDetailLocalSlot(); + return localSlot ? this.canvasLocalSlot([ + localSlot.left, + localSlot.top, + localSlot.width, + localSlot.height + ]) : null; + } + /** Project one node-local canvas rectangle into viewport coordinates. */ + canvasLocalSlot(rect) { + const projection = this.cubeFaceProjection(); + if (projection) { + try { + const slot = projection.projectRect(rect); + if (validSlot(slot)) return slot; + } catch { + return null; + } + } + const canvasApi = this.options.app.canvas; + const nodePosition = this.options.node.pos; + if (!canvasApi || !nodePosition) return null; + const [x, y, width, height] = rect; + const canvasRect = canvasApi.canvas.getBoundingClientRect(); + const topLeft = canvasApi.convertOffsetToCanvas([ + nodePosition[0] + x, + nodePosition[1] + y + ]); + const bottomRight = canvasApi.convertOffsetToCanvas([ + nodePosition[0] + x + width, + nodePosition[1] + y + height + ]); + return { + left: canvasRect.left + topLeft[0], + top: canvasRect.top + topLeft[1], + width: bottomRight[0] - topLeft[0], + height: bottomRight[1] - topLeft[1] + }; + } + /** Read SugarCubes' renderer-neutral projection contract when this node is embedded. */ + cubeFaceProjection() { + const value = Reflect.get( + this.options.node, + CUBE_FACE_PROJECTION_SYMBOL + ); + if (typeof value !== "object" || value === null) return null; + const candidate = value; + if (!(candidate.container instanceof HTMLElement) || typeof candidate.projectRect !== "function") { + return null; + } + return candidate; } captureSurface() { const domImages = this.domImages(); @@ -1080,7 +1353,9 @@ var OrderedMediaPreviewActions = class { } return { items: previewItems(canvasImages), - slots: canvasImages.map((_, index) => () => this.canvasSlots()[index]), + slots: canvasImages.map( + (_, index) => () => this.canvasSlots()[index] + ), apply: (items) => { this.options.node.imgs = items.map((item) => item.image); delete this.options.node.imageRects; @@ -1136,6 +1411,84 @@ var OrderedMediaPreviewActions = class { isGridVisible() { return !this.options.node.flags?.collapsed && this.options.node.imageIndex == null && this.itemCount() > 1; } + /** Read detail selection from the renderer that currently owns it. */ + selectedItemIndex() { + const index = this.options.node.imageIndex; + if (typeof index === "number" && Number.isInteger(index) && index >= 0 && index < this.itemCount()) { + return index; + } + const currentButton = this.domDetailButtons().find( + (button) => button.getAttribute("aria-current") === "true" + ); + if (!currentButton) return null; + const detailIndex = this.domDetailButtons().indexOf(currentButton); + return detailIndex >= 0 && detailIndex < this.itemCount() ? detailIndex : null; + } + /** Keep the moved item selected across both native renderer state models. */ + followMovedDetail(index, destination) { + if (this.selectedItemIndex() !== index) return; + this.pendingDetailIndex = destination; + if (this.options.node.imageIndex === index) { + this.options.node.imageIndex = destination; + } else { + this.domDetailButtons()[destination]?.click(); + } + this.affordances.refresh(); + } + /** Select the nearest remaining item after removing an inspected item. */ + followRemovedDetail(index, removedDetail) { + if (!removedDetail) return; + const remaining = this.itemCount(); + const destination = remaining > 0 ? Math.min(index, remaining - 1) : null; + this.pendingDetailIndex = destination; + if (this.options.node.imageIndex === index) { + this.options.node.imageIndex = destination; + } else if (destination !== null) { + this.domDetailButtons()[destination]?.click(); + } + this.affordances.refresh(); + } + /** Re-enter Nodes 2.0 detail mode after output publication resets its grid. */ + restorePendingDetail() { + if (this.pendingDetailIndex === null || this.detailRestoreFrame !== null) { + return; + } + let stableFrames = 0; + let remainingFrames = 12; + const restore = () => { + this.detailRestoreFrame = null; + const destination = this.pendingDetailIndex; + if (destination === null) return; + const buttons = this.domDetailButtons(); + const selected = buttons.findIndex( + (button) => button.getAttribute("aria-current") === "true" + ); + if (selected === destination) { + stableFrames += 1; + } else { + stableFrames = 0; + buttons[destination]?.click(); + } + remainingFrames -= 1; + if (stableFrames >= 3 || remainingFrames <= 0) { + this.pendingDetailIndex = null; + this.affordances.refresh(); + return; + } + this.detailRestoreFrame = requestAnimationFrame(restore); + }; + this.detailRestoreFrame = requestAnimationFrame(restore); + } + /** Return Comfy's ordered detail navigation controls for this node. */ + domDetailButtons() { + const root = this.domRoot(); + if (!root) return []; + return Array.from( + root.querySelectorAll( + 'button[aria-label^="View image "]' + ) + ); + } nodeId() { const nodeId = this.options.node.id; return nodeId === void 0 ? void 0 : String(nodeId); @@ -1148,16 +1501,36 @@ function previewItems(images) { })); } function imageSlot(image) { - return () => { - const rect = image.getBoundingClientRect(); - return { - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height - }; + return () => elementSlot(image); +} +function elementSlot(element) { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height }; } +function validSlot(slot) { + return Number.isFinite(slot.left) && Number.isFinite(slot.top) && Number.isFinite(slot.width) && Number.isFinite(slot.height) && slot.width >= 0 && slot.height >= 0; +} +function imageArea(image) { + const rect = image.getBoundingClientRect(); + return rect.width * rect.height; +} +function indexedSlots(slots, container = null) { + return slots.map( + (bounds, itemIndex) => container ? { itemIndex, bounds, container } : { itemIndex, bounds } + ); +} +function unionImageRects(rects) { + const left = Math.min(...rects.map(([x]) => x)); + const top = Math.min(...rects.map(([, y]) => y)); + const right = Math.max(...rects.map(([x, , width]) => x + width)); + const bottom = Math.max(...rects.map(([, y, , height]) => y + height)); + return [left, top, right - left, bottom - top]; +} // web/src/orderedMediaSelection.ts var OrderedMediaSelection = class { @@ -1331,8 +1704,12 @@ function configureOrderedMediaNode(candidate, app2, api, config, logger = consol const restoreExternalUploads = wrapExternalAppendUploads(candidate, beginAppend); const originalOnGraphConfigured = candidate.onGraphConfigured; candidate.onGraphConfigured = function(...args) { + const configuredValue = imageWidget.value; + const serializedValue = configuredWidgetValue(candidate, imageWidget); const result = originalOnGraphConfigured?.apply(this, args); - const restored = selection.replace(imageWidget.value); + const restored = selection.replace( + Array.isArray(configuredValue) ? configuredValue : Array.isArray(serializedValue) ? serializedValue : imageWidget.value + ); if (!Array.isArray(imageWidget.value)) setPersistedFiles(restored); refresh(restored); return result; @@ -1348,6 +1725,10 @@ function configureOrderedMediaNode(candidate, app2, api, config, logger = consol if (!Array.isArray(imageWidget.value)) setPersistedFiles(initial); refresh(initial); } +function configuredWidgetValue(node, widget) { + const index = node.widgets?.indexOf(widget) ?? -1; + return index >= 0 ? node.widgets_values?.[index] : void 0; +} function registerOrderedMediaNode(app2, api, extensionName, config, logger = console) { app2.registerExtension({ name: extensionName, diff --git a/web/src/nativeNodePreview.ts b/web/src/nativeNodePreview.ts index 4ef271c..eeedab0 100644 --- a/web/src/nativeNodePreview.ts +++ b/web/src/nativeNodePreview.ts @@ -5,12 +5,15 @@ import type { ComfyApi, ComfyApp, + ComfyGraph, + ComfyGraphNode, ComfyNodeExecutionOutput } from "./types"; import type { OrderedMediaPreviewTarget } from "./orderedMediaPreview"; interface PreviewNode { readonly id?: string | number; + readonly graph?: ComfyGraph; } /** Feed input-backed media through the same output path as Preview Image. */ @@ -25,15 +28,19 @@ export class NativeNodePreview implements OrderedMediaPreviewTarget { /** Publish media as an execution-shaped output for both node renderers. */ publish(output: ComfyNodeExecutionOutput): void { - const nodeId = this.nodeId(); - if (!nodeId) return; + const localNodeId = this.localNodeId(); + if (!localNodeId) return; + const outputLocator = this.outputLocator(localNodeId); this.app.nodeOutputs ??= {}; - this.app.nodeOutputs[nodeId] = output; - this.api.dispatchEvent( - new CustomEvent("executed", { - detail: { node: nodeId, display_node: nodeId, output } - }) - ); + this.app.nodeOutputs[localNodeId] = output; + this.app.nodeOutputs[outputLocator] = output; + for (const executionId of this.executionIds(localNodeId)) { + this.api.dispatchEvent( + new CustomEvent("executed", { + detail: { node: executionId, display_node: executionId, output } + }) + ); + } for (const listener of this.publishListeners) listener(); } @@ -48,8 +55,62 @@ export class NativeNodePreview implements OrderedMediaPreviewTarget { this.publish({ images: [], animated: [] }); } - private nodeId(): string | undefined { + private localNodeId(): string | undefined { if (this.node.id === undefined) return undefined; return String(this.node.id); } + + /** Match the output key selected by Comfy's Nodes 2.0 subgraph renderer. */ + private outputLocator(localNodeId: string): string { + const graphId = this.node.graph?.id; + const isSubgraph = this.node.graph && this.node.graph !== this.app.rootGraph; + return isSubgraph && graphId !== undefined + ? `${String(graphId)}:${localNodeId}` + : localNodeId; + } + + /** Resolve instance paths because Comfy events consume execution IDs, not graph locators. */ + private executionIds(localNodeId: string): string[] { + const graph = this.node.graph; + const rootGraph = this.app.rootGraph; + if (!graph || !rootGraph || graph === rootGraph || graph.isRootGraph) { + return [localNodeId]; + } + const parentPaths = findGraphInstancePaths(rootGraph, graph); + return parentPaths.length > 0 + ? parentPaths.map((path) => `${path}:${localNodeId}`) + : []; + } +} + +/** Find every root-to-instance path that embeds one shared subgraph definition. */ +function findGraphInstancePaths( + root: ComfyGraph, + target: ComfyGraph, + visited: ReadonlySet = new Set() +): string[] { + if (visited.has(root)) return []; + const nextVisited = new Set(visited); + nextVisited.add(root); + const paths: string[] = []; + for (const node of root.nodes ?? []) { + const nodeId = graphNodeId(node); + if (!nodeId || !node.subgraph) continue; + if (node.subgraph === target) paths.push(nodeId); + for (const nestedPath of findGraphInstancePaths( + node.subgraph, + target, + nextVisited + )) { + paths.push(`${nodeId}:${nestedPath}`); + } + } + return paths; +} + +/** Normalize one graph-node identity without accepting empty execution segments. */ +function graphNodeId(node: ComfyGraphNode): string | undefined { + if (node.id === undefined) return undefined; + const identity = String(node.id); + return identity.length > 0 ? identity : undefined; } diff --git a/web/src/nativePreviewLifecycle.ts b/web/src/nativePreviewLifecycle.ts index c68387b..0ed8848 100644 --- a/web/src/nativePreviewLifecycle.ts +++ b/web/src/nativePreviewLifecycle.ts @@ -20,6 +20,7 @@ export function subscribeNativePreviewLifecycle( }; } +/** Observe native preview mounting, source replacement, and detail selection. */ function ensureObserver(): void { if (observer) return; observer = new MutationObserver(() => { @@ -29,6 +30,6 @@ function ensureObserver(): void { childList: true, subtree: true, attributes: true, - attributeFilter: ["src"] + attributeFilter: ["aria-current", "src"] }); } diff --git a/web/src/orderedMediaNode.ts b/web/src/orderedMediaNode.ts index 19c1cc1..ba5dbad 100644 --- a/web/src/orderedMediaNode.ts +++ b/web/src/orderedMediaNode.ts @@ -60,6 +60,7 @@ export interface OrderedMediaNode { constructor: { comfyClass?: string }; id?: string | number; widgets?: OrderedMediaWidget[]; + widgets_values?: unknown[]; pasteFiles?: NativeCallback; onDragDrop?: NativeCallback; onRemoved?: NativeCallback; @@ -70,7 +71,10 @@ export interface OrderedMediaNode { previousValue: unknown, widget: OrderedMediaWidget ) => void; - graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; + graph?: { + id?: string | number; + setDirtyCanvas?: (foreground: boolean, background: boolean) => void; + }; addWidget( type: WidgetType, name: string, @@ -238,8 +242,16 @@ export function configureOrderedMediaNode( const restoreExternalUploads = wrapExternalAppendUploads(candidate, beginAppend); const originalOnGraphConfigured = candidate.onGraphConfigured; candidate.onGraphConfigured = function (...args: unknown[]): unknown { + const configuredValue = imageWidget.value; + const serializedValue = configuredWidgetValue(candidate, imageWidget); const result = originalOnGraphConfigured?.apply(this, args); - const restored = selection.replace(imageWidget.value); + const restored = selection.replace( + Array.isArray(configuredValue) + ? configuredValue + : Array.isArray(serializedValue) + ? serializedValue + : imageWidget.value + ); if (!Array.isArray(imageWidget.value)) setPersistedFiles(restored); refresh(restored); return result; @@ -258,6 +270,15 @@ export function configureOrderedMediaNode( refresh(initial); } +/** Read the persisted value aligned with one native widget when Comfy clears its live value. */ +function configuredWidgetValue( + node: OrderedMediaNode, + widget: OrderedMediaWidget +): unknown { + const index = node.widgets?.indexOf(widget) ?? -1; + return index >= 0 ? node.widgets_values?.[index] : undefined; +} + /** Register one renderer-neutral ordered-media loader extension. */ export function registerOrderedMediaNode( app: ComfyApp, diff --git a/web/src/orderedMediaPreviewActions.ts b/web/src/orderedMediaPreviewActions.ts index ddee14f..67cf2aa 100644 --- a/web/src/orderedMediaPreviewActions.ts +++ b/web/src/orderedMediaPreviewActions.ts @@ -6,6 +6,7 @@ import type { NativeNodePreview } from "./nativeNodePreview"; import { subscribeNativePreviewLifecycle } from "./nativePreviewLifecycle"; import { OrderedMediaPreviewAffordances, + type NativePreviewActionSlot, type NativePreviewSlot } from "./orderedMediaPreviewAffordances"; import { @@ -20,15 +21,34 @@ import { } from "./comfyImageReference"; type NativeImageRect = readonly [number, number, number, number]; +const CUBE_FACE_PROJECTION_SYMBOL = Symbol.for( + "sugarcubes.cube-face-projection.v1" +); + +interface CubeFaceProjection { + readonly container: HTMLElement; + readonly projectRect: (rect: NativeImageRect) => NativePreviewSlot; +} interface ActionPreviewNode { readonly id?: string | number; readonly pos?: readonly [number, number]; + readonly size?: readonly [number, number]; readonly flags?: { readonly collapsed?: boolean }; + widgets?: ActionPreviewWidget[]; imageIndex?: number | null; imageRects?: NativeImageRect[]; imgs?: HTMLImageElement[]; - graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; + graph?: { + _rootGraph?: object; + setDirtyCanvas?: (foreground: boolean, background: boolean) => void; + }; +} + +interface ActionPreviewWidget { + readonly y?: number; + readonly computedHeight?: number; + readonly options?: { readonly canvasOnly?: boolean }; } export interface OrderedMediaPreviewActionOptions { @@ -48,6 +68,9 @@ export class OrderedMediaPreviewActions { private readonly transaction: OrderedMediaPreviewTransaction; private readonly unsubscribePreview: () => void; private readonly unsubscribeLifecycle: () => void; + private lastCanvasPreviewRect: NativeImageRect | null = null; + private pendingDetailIndex: number | null = null; + private detailRestoreFrame: number | null = null; constructor(private readonly options: OrderedMediaPreviewActionOptions) { this.transaction = new OrderedMediaPreviewTransaction({ @@ -57,25 +80,39 @@ export class OrderedMediaPreviewActions { this.affordances.refresh(); } }); + const moveEarlier = (index: number): void => { + const destination = index - 1; + this.transaction.move(index, destination); + this.followMovedDetail(index, destination); + options.moveEarlier(index); + }; + const moveLater = (index: number): void => { + const destination = index + 1; + this.transaction.move(index, destination); + this.followMovedDetail(index, destination); + options.moveLater(index); + }; + const remove = (index: number): void => { + const removedDetail = this.selectedItemIndex() === index; + this.transaction.remove(index); + options.remove(index); + this.followRemovedDetail(index, removedDetail); + }; + const actionOptions = { + getItemCount: () => this.itemCount(), + moveEarlier, + moveLater, + remove + }; this.affordances = new OrderedMediaPreviewAffordances({ itemLabel: options.itemLabel, - getSlots: () => this.nativeSlots(), - moveEarlier: (index) => { - this.transaction.move(index, index - 1); - options.moveEarlier(index); - }, - moveLater: (index) => { - this.transaction.move(index, index + 1); - options.moveLater(index); - }, - remove: (index) => { - this.transaction.remove(index); - options.remove(index); - } + getSlots: () => this.nativeActionSlots(), + ...actionOptions }); this.unsubscribePreview = options.preview.subscribe(() => { this.affordances.refresh(); this.transaction.authoritativePublished(); + this.restorePendingDetail(); }); this.unsubscribeLifecycle = subscribeNativePreviewLifecycle(() => { this.affordances.refresh(); @@ -88,6 +125,10 @@ export class OrderedMediaPreviewActions { this.unsubscribeLifecycle(); this.transaction.dispose(); this.affordances.dispose(); + if (this.detailRestoreFrame !== null) { + cancelAnimationFrame(this.detailRestoreFrame); + this.detailRestoreFrame = null; + } } private itemCount(): number { @@ -130,40 +171,38 @@ export class OrderedMediaPreviewActions { } private canvasSlots(): NativePreviewSlot[] { - const canvasApi = this.options.app.canvas; - const nodePosition = this.options.node.pos; const imageRects = this.options.node.imageRects; - if (!canvasApi || !nodePosition || !imageRects) return []; - const canvasRect = canvasApi.canvas.getBoundingClientRect(); - return imageRects.map(([x, y, width, height]) => { - const topLeft = canvasApi.convertOffsetToCanvas([ - nodePosition[0] + x, - nodePosition[1] + y - ]); - const bottomRight = canvasApi.convertOffsetToCanvas([ - nodePosition[0] + x + width, - nodePosition[1] + y + height - ]); - return { - left: canvasRect.left + topLeft[0], - top: canvasRect.top + topLeft[1], - width: bottomRight[0] - topLeft[0], - height: bottomRight[1] - topLeft[1] - }; - }); + if (!imageRects) return []; + if (imageRects.length > 0) { + this.lastCanvasPreviewRect = unionImageRects(imageRects); + } + const slots: NativePreviewSlot[] = []; + for (const rect of imageRects) { + const slot = this.canvasLocalSlot(rect); + if (slot) slots.push(slot); + } + return slots; } - private nativeSlots(): NativePreviewSlot[] { - if ( - this.options.node.flags?.collapsed || - this.options.node.imageIndex != null - ) { - return []; + private nativeActionSlots(): NativePreviewActionSlot[] { + if (!this.belongsToActiveWorkflow()) return []; + if (this.options.node.flags?.collapsed) return []; + const selectedIndex = this.selectedItemIndex(); + if (selectedIndex !== null) { + return this.detailActionSlots(selectedIndex); } const activeSlots = this.transaction.activeSlots(); - if (activeSlots) return activeSlots; + const domImages = this.domImages(); + if (activeSlots) { + return indexedSlots( + activeSlots, + this.domPreviewContainer(domImages) ?? + this.cubeFaceProjection()?.container ?? + null + ); + } if (!this.isGridVisible()) return []; - const domSlots = this.domImages().map((image) => { + const domSlots = domImages.map((image) => { const rect = image.getBoundingClientRect(); return { left: rect.left, @@ -172,7 +211,180 @@ export class OrderedMediaPreviewActions { height: rect.height }; }); - return domSlots.length === this.itemCount() ? domSlots : this.canvasSlots(); + if (domSlots.length === this.itemCount()) { + return indexedSlots(domSlots, this.domPreviewContainer(domImages)); + } + return indexedSlots( + this.canvasSlots(), + this.cubeFaceProjection()?.container ?? null + ); + } + + /** Prevent inactive workflow tabs with reused node IDs from claiming active Nodes 2 DOM. */ + private belongsToActiveWorkflow(): boolean { + const nodeGraph = this.options.node.graph; + const nodeRoot = nodeGraph?._rootGraph ?? nodeGraph; + const activeRoot = this.options.app.rootGraph ?? this.options.app.canvas?.graph; + return !nodeRoot || !activeRoot || nodeRoot === activeRoot; + } + + /** Expose one selected-item control strip in native detail mode. */ + private detailActionSlots(selectedIndex: number): NativePreviewActionSlot[] { + const domSlot = this.domDetailSlot(selectedIndex); + if (domSlot) return [domSlot]; + const canvasSlot = this.canvasDetailSlot(); + if (!canvasSlot) return []; + const container = this.cubeFaceProjection()?.container; + return [ + container + ? { itemIndex: selectedIndex, bounds: canvasSlot, container } + : { itemIndex: selectedIndex, bounds: canvasSlot } + ]; + } + + /** Locate the selected Nodes 2.0 preview without replacing native UI. */ + private domDetailSlot(selectedIndex: number): NativePreviewActionSlot | null { + const root = this.domRoot(); + const reference = this.currentImages()?.[selectedIndex]; + if (!root || !reference) return null; + const expectedKey = comfyImageReferenceKey(reference); + const candidates = Array.from( + root.querySelectorAll("img") + ).filter((image) => { + const rect = image.getBoundingClientRect(); + return ( + comfyImageSourceKey(image.src) === expectedKey && + rect.width > 0 && + rect.height > 0 + ); + }); + const image = candidates.reduce( + (largest, candidate) => + !largest || imageArea(candidate) > imageArea(largest) + ? candidate + : largest, + null + ); + const previewRegion = root.querySelector( + '[role="region"][aria-label^="Image preview"]' + ); + const previewElement = image ?? previewRegion; + if (!previewElement) return null; + return { + itemIndex: selectedIndex, + bounds: elementSlot(previewElement), + container: + previewRegion ?? + this.domPreviewContainer(image ? [image] : []) ?? + root + }; + } + + /** Return the closest native preview surface shared by rendered media. */ + private domPreviewContainer(images: HTMLImageElement[]): HTMLElement | null { + const root = this.domRoot(); + if (!root || images.length === 0) return null; + const previewRegion = root.querySelector( + '[role="region"][aria-label^="Image preview"]' + ); + if (previewRegion && images.every((image) => previewRegion.contains(image))) { + return previewRegion; + } + let candidate = images[0]?.parentElement ?? null; + while (candidate && candidate !== root) { + if (images.every((image) => candidate?.contains(image) === true)) { + return candidate; + } + candidate = candidate.parentElement; + } + return root; + } + + /** Resolve selected preview geometry in node-local canvas coordinates. */ + private canvasDetailLocalSlot(): NativePreviewSlot | null { + const previewWidget = this.options.node.widgets?.find( + (widget) => + widget.options?.canvasOnly === true && + typeof widget.y === "number" && + typeof widget.computedHeight === "number" && + widget.computedHeight > 0 + ); + const nodeWidth = this.options.node.size?.[0]; + const previewY = previewWidget?.y; + const previewHeight = previewWidget?.computedHeight; + if ( + typeof nodeWidth !== "number" || + nodeWidth <= 0 || + typeof previewY !== "number" || + typeof previewHeight !== "number" + ) { + if (!this.lastCanvasPreviewRect) return null; + const [left, top, width, height] = this.lastCanvasPreviewRect; + return { left, top, width, height }; + } + return { left: 0, top: previewY, width: nodeWidth, height: previewHeight }; + } + + /** Locate the selected Nodes 1.0 preview from its canvas widget geometry. */ + private canvasDetailSlot(): NativePreviewSlot | null { + const localSlot = this.canvasDetailLocalSlot(); + return localSlot + ? this.canvasLocalSlot([ + localSlot.left, + localSlot.top, + localSlot.width, + localSlot.height + ]) + : null; + } + + /** Project one node-local canvas rectangle into viewport coordinates. */ + private canvasLocalSlot(rect: NativeImageRect): NativePreviewSlot | null { + const projection = this.cubeFaceProjection(); + if (projection) { + try { + const slot = projection.projectRect(rect); + if (validSlot(slot)) return slot; + } catch { + return null; + } + } + const canvasApi = this.options.app.canvas; + const nodePosition = this.options.node.pos; + if (!canvasApi || !nodePosition) return null; + const [x, y, width, height] = rect; + const canvasRect = canvasApi.canvas.getBoundingClientRect(); + const topLeft = canvasApi.convertOffsetToCanvas([ + nodePosition[0] + x, + nodePosition[1] + y + ]); + const bottomRight = canvasApi.convertOffsetToCanvas([ + nodePosition[0] + x + width, + nodePosition[1] + y + height + ]); + return { + left: canvasRect.left + topLeft[0], + top: canvasRect.top + topLeft[1], + width: bottomRight[0] - topLeft[0], + height: bottomRight[1] - topLeft[1] + }; + } + + /** Read SugarCubes' renderer-neutral projection contract when this node is embedded. */ + private cubeFaceProjection(): CubeFaceProjection | null { + const value = Reflect.get( + this.options.node, + CUBE_FACE_PROJECTION_SYMBOL + ) as unknown; + if (typeof value !== "object" || value === null) return null; + const candidate = value as Partial; + if ( + !(candidate.container instanceof HTMLElement) || + typeof candidate.projectRect !== "function" + ) { + return null; + } + return candidate as CubeFaceProjection; } private captureSurface(): OrderedMediaPreviewSurface | null { @@ -184,7 +396,9 @@ export class OrderedMediaPreviewActions { } return { items: previewItems(canvasImages), - slots: canvasImages.map((_, index) => () => this.canvasSlots()[index] as NativePreviewSlot), + slots: canvasImages.map( + (_, index) => () => this.canvasSlots()[index] as NativePreviewSlot + ), apply: (items) => { this.options.node.imgs = items.map((item) => item.image); delete this.options.node.imageRects; @@ -257,6 +471,96 @@ export class OrderedMediaPreviewActions { ); } + /** Read detail selection from the renderer that currently owns it. */ + private selectedItemIndex(): number | null { + const index = this.options.node.imageIndex; + if ( + typeof index === "number" && + Number.isInteger(index) && + index >= 0 && + index < this.itemCount() + ) { + return index; + } + const currentButton = this.domDetailButtons().find( + (button) => button.getAttribute("aria-current") === "true" + ); + if (!currentButton) return null; + const detailIndex = this.domDetailButtons().indexOf(currentButton); + return detailIndex >= 0 && detailIndex < this.itemCount() + ? detailIndex + : null; + } + + /** Keep the moved item selected across both native renderer state models. */ + private followMovedDetail(index: number, destination: number): void { + if (this.selectedItemIndex() !== index) return; + this.pendingDetailIndex = destination; + if (this.options.node.imageIndex === index) { + this.options.node.imageIndex = destination; + } else { + this.domDetailButtons()[destination]?.click(); + } + this.affordances.refresh(); + } + + /** Select the nearest remaining item after removing an inspected item. */ + private followRemovedDetail(index: number, removedDetail: boolean): void { + if (!removedDetail) return; + const remaining = this.itemCount(); + const destination = remaining > 0 ? Math.min(index, remaining - 1) : null; + this.pendingDetailIndex = destination; + if (this.options.node.imageIndex === index) { + this.options.node.imageIndex = destination; + } else if (destination !== null) { + this.domDetailButtons()[destination]?.click(); + } + this.affordances.refresh(); + } + + /** Re-enter Nodes 2.0 detail mode after output publication resets its grid. */ + private restorePendingDetail(): void { + if (this.pendingDetailIndex === null || this.detailRestoreFrame !== null) { + return; + } + let stableFrames = 0; + let remainingFrames = 12; + const restore = (): void => { + this.detailRestoreFrame = null; + const destination = this.pendingDetailIndex; + if (destination === null) return; + const buttons = this.domDetailButtons(); + const selected = buttons.findIndex( + (button) => button.getAttribute("aria-current") === "true" + ); + if (selected === destination) { + stableFrames += 1; + } else { + stableFrames = 0; + buttons[destination]?.click(); + } + remainingFrames -= 1; + if (stableFrames >= 3 || remainingFrames <= 0) { + this.pendingDetailIndex = null; + this.affordances.refresh(); + return; + } + this.detailRestoreFrame = requestAnimationFrame(restore); + }; + this.detailRestoreFrame = requestAnimationFrame(restore); + } + + /** Return Comfy's ordered detail navigation controls for this node. */ + private domDetailButtons(): HTMLButtonElement[] { + const root = this.domRoot(); + if (!root) return []; + return Array.from( + root.querySelectorAll( + 'button[aria-label^="View image "]' + ) + ); + } + private nodeId(): string | undefined { const nodeId = this.options.node.id; return nodeId === undefined ? undefined : String(nodeId); @@ -271,13 +575,52 @@ function previewItems(images: HTMLImageElement[]): OrderedMediaPreviewItem[] { } function imageSlot(image: HTMLImageElement): () => NativePreviewSlot { - return () => { - const rect = image.getBoundingClientRect(); - return { - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height - }; + return () => elementSlot(image); +} + +function elementSlot(element: Element): NativePreviewSlot { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height }; } + +/** Reject malformed cross-extension projection geometry. */ +function validSlot(slot: NativePreviewSlot): boolean { + return ( + Number.isFinite(slot.left) && + Number.isFinite(slot.top) && + Number.isFinite(slot.width) && + Number.isFinite(slot.height) && + slot.width >= 0 && + slot.height >= 0 + ); +} + +/** Measure a rendered image when native detail contains multiple candidates. */ +function imageArea(image: HTMLImageElement): number { + const rect = image.getBoundingClientRect(); + return rect.width * rect.height; +} + +/** Attach authoritative list positions to native preview footprints. */ +function indexedSlots( + slots: NativePreviewSlot[], + container: HTMLElement | null = null +): NativePreviewActionSlot[] { + return slots.map((bounds, itemIndex) => + container ? { itemIndex, bounds, container } : { itemIndex, bounds } + ); +} + +/** Return the smallest node-local rectangle containing every grid cell. */ +function unionImageRects(rects: NativeImageRect[]): NativeImageRect { + const left = Math.min(...rects.map(([x]) => x)); + const top = Math.min(...rects.map(([, y]) => y)); + const right = Math.max(...rects.map(([x, , width]) => x + width)); + const bottom = Math.max(...rects.map(([, y, , height]) => y + height)); + return [left, top, right - left, bottom - top]; +} diff --git a/web/src/orderedMediaPreviewAffordances.ts b/web/src/orderedMediaPreviewAffordances.ts index 9285d3f..23ca5eb 100644 --- a/web/src/orderedMediaPreviewAffordances.ts +++ b/web/src/orderedMediaPreviewAffordances.ts @@ -14,9 +14,17 @@ export interface NativePreviewSlot { readonly height: number; } +/** Bind one native preview footprint to its authoritative media position. */ +export interface NativePreviewActionSlot { + readonly itemIndex: number; + readonly bounds: NativePreviewSlot; + readonly container?: HTMLElement; +} + export interface OrderedMediaPreviewAffordanceOptions { readonly itemLabel: string; - readonly getSlots: () => NativePreviewSlot[]; + readonly getSlots: () => NativePreviewActionSlot[]; + readonly getItemCount: () => number; readonly moveEarlier: (index: number) => void; readonly moveLater: (index: number) => void; readonly remove: (index: number) => void; @@ -32,15 +40,19 @@ interface AffordanceElements { /** Overlay compact controls without replacing Comfy's native preview surface. */ export class OrderedMediaPreviewAffordances { private elements: AffordanceElements[] = []; + private readonly positionedContainers = new Map(); private animationFrame: number | null = null; private remainingSyncFrames = 0; + private viewportControlsSuspended = false; constructor(private readonly options: OrderedMediaPreviewAffordanceOptions) { window.addEventListener("resize", this.requestRefresh, true); document.addEventListener("click", this.requestRefresh, true); document.addEventListener("keydown", this.requestRefresh, true); + document.addEventListener("pointerdown", this.suspendViewportControls, true); document.addEventListener("pointermove", this.requestRefresh, true); - document.addEventListener("pointerup", this.requestRefresh, true); + document.addEventListener("pointerup", this.resumeViewportControls, true); + document.addEventListener("pointercancel", this.resumeViewportControls, true); document.addEventListener("wheel", this.requestRefresh, true); this.refresh(); } @@ -56,8 +68,10 @@ export class OrderedMediaPreviewAffordances { window.removeEventListener("resize", this.requestRefresh, true); document.removeEventListener("click", this.requestRefresh, true); document.removeEventListener("keydown", this.requestRefresh, true); + document.removeEventListener("pointerdown", this.suspendViewportControls, true); document.removeEventListener("pointermove", this.requestRefresh, true); - document.removeEventListener("pointerup", this.requestRefresh, true); + document.removeEventListener("pointerup", this.resumeViewportControls, true); + document.removeEventListener("pointercancel", this.resumeViewportControls, true); document.removeEventListener("wheel", this.requestRefresh, true); if (this.animationFrame !== null) { cancelAnimationFrame(this.animationFrame); @@ -65,6 +79,7 @@ export class OrderedMediaPreviewAffordances { } for (const element of this.elements) element.root.remove(); this.elements = []; + this.releasePositionedContainers(new Set()); } private readonly requestRefresh = (): void => { @@ -72,6 +87,20 @@ export class OrderedMediaPreviewAffordances { this.scheduleSync(); }; + private readonly suspendViewportControls = (event: Event): void => { + if (!(event.target instanceof HTMLCanvasElement)) return; + this.viewportControlsSuspended = true; + for (const elements of this.elements) { + if (elements.root.parentElement === document.body) elements.root.hidden = true; + } + }; + + private readonly resumeViewportControls = (): void => { + if (!this.viewportControlsSuspended) return; + this.viewportControlsSuspended = false; + this.refresh(); + }; + private scheduleSync(): void { if (this.animationFrame !== null) return; this.animationFrame = requestAnimationFrame(() => { @@ -84,43 +113,107 @@ export class OrderedMediaPreviewAffordances { private sync(): void { const slots = this.options.getSlots(); + const activeContainers = new Set(); this.resizeElements(slots.length); for (const [index, elements] of this.elements.entries()) { - const slot = slots[index]; - if (!slot || slot.width <= 0 || slot.height <= 0) { + const actionSlot = slots[index]; + const slot = actionSlot?.bounds; + if ( + !actionSlot || + !slot || + slot.width <= 0 || + slot.height <= 0 || + (!actionSlot.container && this.viewportControlsSuspended) + ) { elements.root.hidden = true; continue; } + const itemIndex = actionSlot.itemIndex; elements.root.hidden = false; - const stripHeight = Math.max(18, Math.min(26, slot.height * 0.16)); + const position = this.mount(elements.root, actionSlot, activeContainers); + const stripHeight = Math.max(18, Math.min(26, position.height * 0.16)); Object.assign(elements.root.style, { - left: `${String(slot.left)}px`, - top: `${String(slot.top)}px`, - width: `${String(slot.width)}px`, + left: `${String(position.left)}px`, + top: `${String(position.top)}px`, + width: `${String(position.width)}px`, height: `${String(stripHeight)}px` }); - elements.root.dataset.ssMediaIndex = String(index); - elements.earlier.dataset.ssMediaIndex = String(index); - elements.later.dataset.ssMediaIndex = String(index); - elements.remove.dataset.ssMediaIndex = String(index); - setActionAvailability(elements.earlier, index > 0); + elements.root.dataset.ssMediaIndex = String(itemIndex); + elements.earlier.dataset.ssMediaIndex = String(itemIndex); + elements.later.dataset.ssMediaIndex = String(itemIndex); + elements.remove.dataset.ssMediaIndex = String(itemIndex); + setActionAvailability(elements.earlier, itemIndex > 0); setActionAvailability( elements.later, - index < this.elements.length - 1 + itemIndex < this.options.getItemCount() - 1 ); elements.earlier.setAttribute( "aria-label", - `Move ${this.options.itemLabel} ${String(index + 1)} earlier` + `Move ${this.options.itemLabel} ${String(itemIndex + 1)} earlier` ); elements.later.setAttribute( "aria-label", - `Move ${this.options.itemLabel} ${String(index + 1)} later` + `Move ${this.options.itemLabel} ${String(itemIndex + 1)} later` ); elements.remove.setAttribute( "aria-label", - `Remove ${this.options.itemLabel} ${String(index + 1)}` + `Remove ${this.options.itemLabel} ${String(itemIndex + 1)}` ); } + this.releasePositionedContainers(activeContainers); + } + + /** Mount one control strip in the preview surface that owns its geometry. */ + private mount( + root: HTMLElement, + actionSlot: NativePreviewActionSlot, + activeContainers: Set + ): NativePreviewSlot { + const container = actionSlot.container; + if (!container) { + if (root.parentElement !== document.body) document.body.append(root); + root.style.position = "fixed"; + root.style.zIndex = "2"; + return actionSlot.bounds; + } + activeContainers.add(container); + this.positionContainer(container); + if (root.parentElement !== container) container.append(root); + root.style.position = "absolute"; + root.style.zIndex = "1"; + const containerRect = container.getBoundingClientRect(); + const scaleX = containerScale(containerRect.width, container.offsetWidth); + const scaleY = containerScale(containerRect.height, container.offsetHeight); + return { + left: + (actionSlot.bounds.left - containerRect.left) / scaleX - + container.clientLeft + + container.scrollLeft, + top: + (actionSlot.bounds.top - containerRect.top) / scaleY - + container.clientTop + + container.scrollTop, + width: actionSlot.bounds.width / scaleX, + height: actionSlot.bounds.height / scaleY + }; + } + + /** Establish a local containing block without overriding authored positioning. */ + private positionContainer(container: HTMLElement): void { + if (this.positionedContainers.has(container)) return; + const position = getComputedStyle(container).position; + if (position !== "" && position !== "static") return; + this.positionedContainers.set(container, container.style.position); + container.style.position = "relative"; + } + + /** Restore preview surfaces that no longer contain loader controls. */ + private releasePositionedContainers(activeContainers: Set): void { + for (const [container, originalPosition] of this.positionedContainers) { + if (activeContainers.has(container)) continue; + container.style.position = originalPosition; + this.positionedContainers.delete(container); + } } private resizeElements(count: number): void { @@ -135,14 +228,12 @@ export class OrderedMediaPreviewAffordances { root.className = "ss-native-preview-affordance"; Object.assign(root.style, { position: "fixed", - zIndex: "9990", + zIndex: "2", display: "flex", alignItems: "stretch", pointerEvents: "none", overflow: "hidden", - borderRadius: "4px 4px 0 0", - background: "rgba(20, 20, 20, 0.72)", - boxShadow: "inset 0 -1px 0 rgba(255, 255, 255, 0.16)" + background: "rgba(20, 20, 20, 0.72)" }); const earlier = controlButton("pi-arrow-left", "Move earlier"); earlier.setAttribute(MEDIA_MOVE_EARLIER_ATTRIBUTE, "true"); @@ -223,3 +314,10 @@ function stopControlPointerEvent(event: PointerEvent): void { event.preventDefault(); event.stopPropagation(); } + +/** Return a finite rendered-to-local scale for one preview axis. */ +function containerScale(renderedSize: number, localSize: number): number { + if (renderedSize <= 0 || localSize <= 0) return 1; + const scale = renderedSize / localSize; + return Number.isFinite(scale) && scale > 0 ? scale : 1; +} diff --git a/web/src/segPreviewNativeSurface.ts b/web/src/segPreviewNativeSurface.ts index 4d6c529..cdf4fbb 100644 --- a/web/src/segPreviewNativeSurface.ts +++ b/web/src/segPreviewNativeSurface.ts @@ -24,7 +24,10 @@ interface NativePreviewNode { hidden?: boolean; options?: { hidden?: boolean }; }>; - graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void }; + graph?: { + id?: string | number; + setDirtyCanvas?: (foreground: boolean, background: boolean) => void; + }; } type SegPreviewExecutionOutput = ComfyNodeExecutionOutput & diff --git a/web/src/types.ts b/web/src/types.ts index d502e27..951fce3 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -26,8 +26,20 @@ export interface ComfySettingsApi { ): ComfySetting; } +export interface ComfyGraphNode { + readonly id?: string | number; + readonly subgraph?: ComfyGraph; +} + +export interface ComfyGraph { + readonly id?: string | number; + readonly isRootGraph?: boolean; + readonly nodes?: readonly ComfyGraphNode[]; +} + export interface ComfyApp { nodeOutputs?: Record; + rootGraph?: ComfyGraph; canvas?: ComfyCanvasApi; ui: { settings: ComfySettingsApi; @@ -39,6 +51,7 @@ export interface ComfyApp { /** Stable coordinate operations exposed by Comfy's graph canvas. */ export interface ComfyCanvasApi { canvas: HTMLCanvasElement; + graph?: ComfyGraph; graph_mouse?: readonly [number, number]; convertEventToCanvasOffset(event: MouseEvent): [number, number]; convertOffsetToCanvas( diff --git a/web/tests/nativeNodePreview.test.ts b/web/tests/nativeNodePreview.test.ts index 90cc48e..2a8e8d5 100644 --- a/web/tests/nativeNodePreview.test.ts +++ b/web/tests/nativeNodePreview.test.ts @@ -5,7 +5,14 @@ import { describe, expect, it, vi } from "vitest"; import { NativeNodePreview } from "../src/nativeNodePreview"; -import type { ComfyApi, ComfyApp } from "../src/types"; +import type { + ComfyApi, + ComfyApp, + ComfyGraph, + ComfySetting, + ComfySettingDefinition, + SettingValue +} from "../src/types"; describe("NativeNodePreview", () => { it("publishes execution-shaped output through Comfy's native event path", () => { @@ -31,6 +38,78 @@ describe("NativeNodePreview", () => { }); }); + it("publishes subgraph previews under the locator consumed by Nodes 2.0", () => { + const subgraph = { id: "cube-definition" }; + const rootGraph = { + id: "root", + isRootGraph: true, + nodes: [{ id: "cube-instance", subgraph }] + }; + const app = previewApp(rootGraph); + const events = new EventTarget(); + const api = events as ComfyApi; + const listener = vi.fn(); + events.addEventListener("executed", (event: Event) => { + listener((event as CustomEvent).detail); + }); + const preview = new NativeNodePreview(app, api, { + id: "mask-loader", + graph: subgraph + }); + const output = { + images: [{ filename: "mask.png", subfolder: "", type: "temp" as const }] + }; + + preview.publish(output); + + expect(app.nodeOutputs?.["mask-loader"]).toBe(output); + expect(app.nodeOutputs?.["cube-definition:mask-loader"]).toBe(output); + expect(listener).toHaveBeenCalledWith({ + node: "cube-instance:mask-loader", + display_node: "cube-instance:mask-loader", + output + }); + }); + + it("publishes shared subgraph previews for every instance path", () => { + const subgraph = { id: "cube-definition", nodes: [] }; + const nestedGraph = { + id: "nested-definition", + nodes: [{ id: "nested-cube", subgraph }] + }; + const rootGraph = { + id: "root", + isRootGraph: true, + nodes: [ + { id: "cube-a", subgraph }, + { id: "cube-b", subgraph }, + { id: "container", subgraph: nestedGraph } + ] + }; + const app = previewApp(rootGraph); + const events = new EventTarget(); + const api = events as ComfyApi; + const executionIds: string[] = []; + const listener = vi.fn((executionId: string) => { + executionIds.push(executionId); + }); + events.addEventListener("executed", (event: Event) => { + listener(executedNodeId(event)); + }); + const preview = new NativeNodePreview(app, api, { + id: "mask-loader", + graph: subgraph + }); + + preview.clear(); + + expect(executionIds).toEqual([ + "cube-a:mask-loader", + "cube-b:mask-loader", + "container:nested-cube:mask-loader" + ]); + }); + it("clears the same native output surface", () => { const app = { nodeOutputs: {} } as ComfyApp; const api = new EventTarget() as ComfyApi; @@ -56,3 +135,33 @@ describe("NativeNodePreview", () => { }); }); + +function executedNodeId(event: Event): string { + const detail: unknown = (event as CustomEvent).detail; + if ( + typeof detail !== "object" || + detail === null || + !("node" in detail) || + typeof detail.node !== "string" + ) { + throw new TypeError("Executed event did not include a node identity."); + } + return detail.node; +} + +function previewApp(rootGraph: ComfyGraph): ComfyApp { + return { + nodeOutputs: {}, + rootGraph, + ui: { + settings: { + addSetting( + definition: ComfySettingDefinition + ): ComfySetting { + return { value: definition.defaultValue }; + } + } + }, + registerExtension: () => undefined + }; +} diff --git a/web/tests/orderedMediaNode.test.ts b/web/tests/orderedMediaNode.test.ts index 06d7b1b..5921ccb 100644 --- a/web/tests/orderedMediaNode.test.ts +++ b/web/tests/orderedMediaNode.test.ts @@ -400,7 +400,9 @@ describe("ordered-media node integration", () => { it("normalizes scalar workflows and survives Nodes 2.0 reactive assignments", async () => { const fixture = configured("saved.png", true); - fixture.node.onGraphConfigured?.(); + const onGraphConfigured = fixture.node.onGraphConfigured; + if (!onGraphConfigured) throw new Error("Expected graph configuration callback."); + onGraphConfigured(); expect(fixture.imageWidget.value).toEqual(["saved.png"]); await vi.waitFor(() => { @@ -410,6 +412,53 @@ describe("ordered-media node integration", () => { }); }); + it("retains array-valued media when the legacy graph lifecycle clears the widget", async () => { + const fixture = createFixture(["one.png", "two.png"]); + fixture.node.onGraphConfigured = () => { + fixture.imageWidget.value = undefined; + }; + configureOrderedMediaNode( + fixture.node, + fixture.app, + fixture.api, + CONFIG + ); + fixture.node.onGraphConfigured(); + + expect(fixture.imageWidget.value).toEqual(["one.png", "two.png"]); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("one.png", "two.png") + ); + }); + }); + + it("restores persisted widget values when Comfy clears the live media widget", async () => { + const fixture = createFixture(undefined); + fixture.node.widgets_values = [ + ["one.png", "two.png"], + "image", + "ordered_media", + "ordered_media" + ]; + configureOrderedMediaNode( + fixture.node, + fixture.app, + fixture.api, + CONFIG + ); + fixture.imageWidget.value = undefined; + + fixture.node.onGraphConfigured?.(); + + expect(fixture.imageWidget.value).toEqual(["one.png", "two.png"]); + await vi.waitFor(() => { + expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual( + references("one.png", "two.png") + ); + }); + }); + it("restores native handlers and clears preview on node removal", () => { const fixture = createFixture(["one.png"]); const paste = fixture.node.pasteFiles; @@ -511,6 +560,7 @@ function createFixture(value: unknown, reactive = false) { constructor: { comfyClass: "SimpleSyrup.LoadImageList" }, id: 7, widgets, + widgets_values: undefined as unknown[] | undefined, imgs: undefined as HTMLImageElement[] | undefined, graph: { setDirtyCanvas: vi.fn() }, pasteFiles: vi.fn(() => true) as (...args: unknown[]) => unknown, diff --git a/web/tests/orderedMediaPreviewActions.test.ts b/web/tests/orderedMediaPreviewActions.test.ts index e770a2c..ac783a2 100644 --- a/web/tests/orderedMediaPreviewActions.test.ts +++ b/web/tests/orderedMediaPreviewActions.test.ts @@ -16,6 +16,39 @@ afterEach(() => { }); describe("OrderedMediaPreviewActions", () => { + it("keeps Nodes 2.0 controls within the native preview stacking context", async () => { + actionFixture(["one.png", "two.png"]); + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + const previewSurface = document.createElement("div"); + previewSurface.setAttribute("role", "region"); + previewSurface.setAttribute( + "aria-label", + "Image preview - Use arrow keys to navigate between images" + ); + mockRect(previewSurface, 40, 60, 200, 100); + const firstImage = previewImage("one.png", 50); + const secondImage = previewImage("two.png", 130); + mockRect(firstImage, 50, 70, 80, 80); + mockRect(secondImage, 130, 70, 80, 80); + previewSurface.append(firstImage, secondImage); + root.append(previewSurface); + document.body.append(root); + + await vi.waitFor(() => { + expect( + previewSurface.querySelectorAll(".ss-native-preview-affordance") + ).toHaveLength(2); + }); + const first = previewSurface.querySelector( + ".ss-native-preview-affordance" + ); + expect(first?.style.position).toBe("absolute"); + expect(first?.style.left).toBe("10px"); + expect(first?.style.top).toBe("10px"); + expect(first?.style.zIndex).toBe("1"); + }); + it("discovers Nodes 2.0 thumbnails by output reference", async () => { const fixture = actionFixture(["one.png", "two.png"]); const root = document.createElement("section"); @@ -43,6 +76,23 @@ describe("OrderedMediaPreviewActions", () => { expect(fixture.remove).toHaveBeenCalledWith(1); }); + it("does not claim Nodes 2.0 DOM owned by another open workflow", async () => { + const inactiveRoot = {}; + const fixture = actionFixture(["one.png", "two.png"], { + graph: { _rootGraph: inactiveRoot } + }); + fixture.app.rootGraph = {}; + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + root.append(previewImage("one.png", 0), previewImage("two.png", 100)); + document.body.append(root); + document.dispatchEvent(new MouseEvent("click")); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(0); + }); + it("positions controls from Nodes 1.0 imageRects", async () => { const canvas = document.createElement("canvas"); mockRect(canvas, 10, 20, 500, 500); @@ -75,27 +125,176 @@ describe("OrderedMediaPreviewActions", () => { expect(first?.style.width).toBe("80px"); }); - it("restores controls when native detail view returns to the grid", async () => { - const node = { id: 7, imageIndex: null as number | null }; + it("anchors embedded Nodes 1.0 controls to SugarCubes' clipped face projection", async () => { + const canvas = document.createElement("canvas"); + const container = document.createElement("div"); + mockRect(canvas, 0, 0, 800, 600); + mockRect(container, 200, 300, 320, 240); + const projectRect = vi.fn( + ([left, top, width, height]: readonly [number, number, number, number]) => ({ + left: 200 + left, + top: 300 + top, + width, + height + }) + ); + const projectionSymbol = Symbol.for("sugarcubes.cube-face-projection.v1"); + const fixture = actionFixture( + ["one.png", "two.png"], + { + pos: [0, 0], + imageIndex: null, + imageRects: [ + [10, 30, 80, 80], + [90, 30, 80, 80] + ], + [projectionSymbol]: { container, projectRect } + } + ); + const nativeProjection = vi.fn(() => { + throw new Error("Root graph projection must not run for an embedded node."); + }); + fixture.app.canvas = { + canvas, + convertEventToCanvasOffset: (event) => [event.clientX, event.clientY], + convertOffsetToCanvas: nativeProjection + }; + document.body.append(canvas, container); + + await vi.waitFor(() => { + expect(container.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2); + }); + const first = container.querySelector( + ".ss-native-preview-affordance" + ); + expect(projectRect).toHaveBeenCalledWith([10, 30, 80, 80]); + expect(nativeProjection).not.toHaveBeenCalled(); + expect(first?.style.position).toBe("absolute"); + expect(first?.style.left).toBe("10px"); + expect(first?.style.top).toBe("30px"); + expect(first?.style.width).toBe("80px"); + }); + + it("positions selected-item controls in Nodes 1.0 detail view", async () => { + const canvas = document.createElement("canvas"); + mockRect(canvas, 10, 20, 500, 500); + const fixture = actionFixture(["one.png", "two.png"], { + pos: [100, 200], + size: [160, 260], + imageIndex: 1, + widgets: [ + { + y: 30, + computedHeight: 200, + options: { canvasOnly: true } + } + ] + }); + fixture.app.canvas = { + canvas, + convertEventToCanvasOffset: (event) => [ + event.clientX - 10, + event.clientY - 20 + ], + convertOffsetToCanvas: (position) => [...position] + }; + document.body.append(canvas); + + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1); + }); + expect( + document.querySelector('[aria-label="Move image 2 earlier"]') + ).not.toBeNull(); + expect( + document.querySelector( + '[aria-label="Move image 2 later"]' + )?.disabled + ).toBe(true); + expect( + document.querySelector(".ss-native-preview-affordance") + ?.style.width + ).toBe("160px"); + }); + + it("reuses the Nodes 1.0 grid footprint after detail clears imageRects", async () => { + const canvas = document.createElement("canvas"); + mockRect(canvas, 10, 20, 500, 500); + const node = { + id: 7, + pos: [100, 200] as const, + imageIndex: null as number | null, + imageRects: [ + [0, 30, 80, 80], + [80, 30, 80, 80] + ] as Array + }; const fixture = actionFixture(["one.png", "two.png"], node); - const root = document.createElement("section"); - root.dataset.nodeId = "7"; - root.append(previewImage("one.png", 0), previewImage("two.png", 100)); + fixture.app.canvas = { + canvas, + convertEventToCanvasOffset: (event) => [ + event.clientX - 10, + event.clientY - 20 + ], + convertOffsetToCanvas: (position) => [...position] + }; + document.body.append(canvas); + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2); + }); + + fixture.node.imageIndex = 1; + delete (fixture.node as { imageRects?: unknown }).imageRects; + document.dispatchEvent(new MouseEvent("click")); + + await vi.waitFor(() => { + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1); + }); + expect( + document.querySelector('[aria-label="Move image 2 earlier"]') + ).not.toBeNull(); + expect( + document.querySelector(".ss-native-preview-affordance") + ?.style.width + ).toBe("160px"); + }); + + it("keeps selected-item controls while native detail view is open", async () => { + const node = { id: 7 }; + const fixture = actionFixture(["one.png", "two.png"], node); + const { root, select } = nativeDetailPreview(); document.body.append(root); - await vi.waitFor(() => { - expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2); - }); + select(0); - fixture.node.imageIndex = 0; - document.dispatchEvent(new MouseEvent("click")); await vi.waitFor(() => { - expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(0); + expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1); }); - - fixture.node.imageIndex = null; - document.dispatchEvent(new MouseEvent("click")); + document + .querySelector('[aria-label="Move image 1 later"]') + ?.click(); await vi.waitFor(() => { - expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2); + expect( + document.querySelector('[aria-label="Remove image 2"]') + ).not.toBeNull(); + }); + expect(fixture.moveLater).toHaveBeenCalledWith(0); + expect( + root.querySelector('[aria-label="View image 2 of 2"]')?.getAttribute( + "aria-current" + ) + ).toBe("true"); + + select(-1); + fixture.preview.publish({ + images: references("two.png", "one.png"), + animated: [false, false] + }); + await vi.waitFor(() => { + expect( + root.querySelector('[aria-label="View image 2 of 2"]')?.getAttribute( + "aria-current" + ) + ).toBe("true"); }); }); @@ -125,6 +324,12 @@ function actionFixture( const node = { id: 7, ...nodeOverrides } as { id: number; imageIndex?: number | null; + imageRects?: Array; + widgets?: Array<{ + y?: number; + computedHeight?: number; + options?: { canvasOnly?: boolean }; + }>; }; const preview = new NativeNodePreview(app, api, node); const moveEarlier = vi.fn(); @@ -141,7 +346,7 @@ function actionFixture( remove }); adapters.push(adapter); - return { adapter, app, moveEarlier, moveLater, node, remove }; + return { adapter, app, moveEarlier, moveLater, node, preview, remove }; } function previewImage(filename: string, left: number): HTMLImageElement { @@ -151,6 +356,42 @@ function previewImage(filename: string, left: number): HTMLImageElement { return image; } +function nativeDetailPreview(): { + root: HTMLElement; + select: (index: number) => void; +} { + const root = document.createElement("section"); + root.dataset.nodeId = "7"; + const region = document.createElement("div"); + region.setAttribute("role", "region"); + region.setAttribute( + "aria-label", + "Image preview - Use arrow keys to navigate between images" + ); + mockRect(region, 20, 30, 160, 200); + const navigation = document.createElement("div"); + const select = (index: number): void => { + for (const [buttonIndex, button] of buttons.entries()) { + if (buttonIndex === index) button.setAttribute("aria-current", "true"); + else button.removeAttribute("aria-current"); + } + }; + const buttons = [0, 1].map((index) => { + const button = document.createElement("button"); + button.setAttribute( + "aria-label", + `View image ${String(index + 1)} of 2` + ); + button.addEventListener("click", () => { + select(index); + }); + return button; + }); + navigation.append(...buttons); + root.append(region, navigation); + return { root, select }; +} + function mockRect( element: Element, left: number, diff --git a/web/tests/orderedMediaPreviewAffordances.test.ts b/web/tests/orderedMediaPreviewAffordances.test.ts index 25b42b6..f99bd45 100644 --- a/web/tests/orderedMediaPreviewAffordances.test.ts +++ b/web/tests/orderedMediaPreviewAffordances.test.ts @@ -15,6 +15,80 @@ afterEach(() => { }); describe("OrderedMediaPreviewAffordances", () => { + it("mounts controls inside their native preview surface", async () => { + const previewSurface = document.createElement("div"); + previewSurface.getBoundingClientRect = () => new DOMRect(100, 200, 240, 180); + document.body.append(previewSurface); + affordances = new OrderedMediaPreviewAffordances({ + itemLabel: "mask", + getSlots: () => [ + { + itemIndex: 0, + bounds: { left: 110, top: 220, width: 80, height: 100 }, + container: previewSurface + } + ], + getItemCount: () => 1, + moveEarlier: vi.fn(), + moveLater: vi.fn(), + remove: vi.fn() + }); + + await vi.waitFor(() => { + expect(previewSurface.querySelector(".ss-native-preview-affordance")).not.toBeNull(); + }); + const root = previewSurface.querySelector( + ".ss-native-preview-affordance" + ); + expect(previewSurface.style.position).toBe("relative"); + expect(root?.style.position).toBe("absolute"); + expect(root?.style.zIndex).toBe("1"); + expect(root?.style.left).toBe("10px"); + expect(root?.style.top).toBe("20px"); + expect(root?.style.borderRadius).toBe(""); + expect(root?.style.boxShadow).toBe(""); + + affordances.dispose(); + affordances = undefined; + expect(previewSurface.style.position).toBe(""); + expect(previewSurface.querySelector(".ss-native-preview-affordance")).toBeNull(); + }); + + it("converts viewport geometry into a transformed preview surface", async () => { + const previewSurface = document.createElement("div"); + Object.defineProperties(previewSurface, { + offsetWidth: { value: 100 }, + offsetHeight: { value: 80 } + }); + previewSurface.getBoundingClientRect = () => new DOMRect(100, 200, 200, 160); + document.body.append(previewSurface); + affordances = new OrderedMediaPreviewAffordances({ + itemLabel: "mask", + getSlots: () => [ + { + itemIndex: 0, + bounds: { left: 120, top: 220, width: 80, height: 100 }, + container: previewSurface + } + ], + getItemCount: () => 1, + moveEarlier: vi.fn(), + moveLater: vi.fn(), + remove: vi.fn() + }); + + await vi.waitFor(() => { + expect(previewSurface.querySelector(".ss-native-preview-affordance")).not.toBeNull(); + }); + const root = previewSurface.querySelector( + ".ss-native-preview-affordance" + ); + expect(root?.style.left).toBe("10px"); + expect(root?.style.top).toBe("10px"); + expect(root?.style.width).toBe("40px"); + expect(root?.style.height).toBe("18px"); + }); + it("positions native-icon list actions over every native slot", async () => { const moveEarlier = vi.fn(); const moveLater = vi.fn(); @@ -22,9 +96,16 @@ describe("OrderedMediaPreviewAffordances", () => { affordances = new OrderedMediaPreviewAffordances({ itemLabel: "mask", getSlots: () => [ - { left: 10, top: 20, width: 100, height: 120 }, - { left: 120, top: 20, width: 80, height: 120 } + { + itemIndex: 0, + bounds: { left: 10, top: 20, width: 100, height: 120 } + }, + { + itemIndex: 1, + bounds: { left: 120, top: 20, width: 80, height: 120 } + } ], + getItemCount: () => 2, moveEarlier, moveLater, remove @@ -72,11 +153,15 @@ describe("OrderedMediaPreviewAffordances", () => { itemLabel: "image", getSlots: () => Array.from({ length: count }, (_, index) => ({ - left: index * 100, - top: 0, - width: 90, - height: 90 + itemIndex: index, + bounds: { + left: index * 100, + top: 0, + width: 90, + height: 90 + } })), + getItemCount: () => count, moveEarlier: vi.fn(), moveLater: vi.fn(), remove: vi.fn() @@ -93,12 +178,20 @@ describe("OrderedMediaPreviewAffordances", () => { }); }); - it("disappears in detail view and returns after native grid navigation", async () => { + it("disappears without a native slot and returns after navigation", async () => { let visible = true; affordances = new OrderedMediaPreviewAffordances({ itemLabel: "image", getSlots: () => - visible ? [{ left: 10, top: 20, width: 100, height: 120 }] : [], + visible + ? [ + { + itemIndex: 0, + bounds: { left: 10, top: 20, width: 100, height: 120 } + } + ] + : [], + getItemCount: () => 1, moveEarlier: vi.fn(), moveLater: vi.fn(), remove: vi.fn() @@ -119,4 +212,42 @@ describe("OrderedMediaPreviewAffordances", () => { expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1); }); }); + + it("hides viewport controls while the canvas is being manipulated", async () => { + const canvas = document.createElement("canvas"); + document.body.append(canvas); + affordances = new OrderedMediaPreviewAffordances({ + itemLabel: "mask", + getSlots: () => [ + { + itemIndex: 0, + bounds: { left: 10, top: 20, width: 100, height: 120 } + } + ], + getItemCount: () => 1, + moveEarlier: vi.fn(), + moveLater: vi.fn(), + remove: vi.fn() + }); + await vi.waitFor(() => { + expect( + document.querySelector(".ss-native-preview-affordance") + ?.hidden + ).toBe(false); + }); + + canvas.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + expect( + document.querySelector(".ss-native-preview-affordance") + ?.hidden + ).toBe(true); + + document.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + await vi.waitFor(() => { + expect( + document.querySelector(".ss-native-preview-affordance") + ?.hidden + ).toBe(false); + }); + }); }); From 2e5e14f6c24642b39d543f926c7c9543740dafb5 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Tue, 4 Aug 2026 21:41:46 -0400 Subject: [PATCH 32/40] fix(mask): preserve missing-alpha image geometry --- simple_syrup/nodes_v3/load_mask_batch.py | 4 +- simple_syrup/runtime/mask_file_loader.py | 11 ++++-- tests/test_load_mask_batch_v3_node.py | 3 ++ tests/test_mask_batch_preview_routes.py | 49 ++++++++++++++++++++++++ tests/test_mask_file_loader.py | 34 ++++++++++++++++ 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/simple_syrup/nodes_v3/load_mask_batch.py b/simple_syrup/nodes_v3/load_mask_batch.py index b13bcc1..5f640d3 100644 --- a/simple_syrup/nodes_v3/load_mask_batch.py +++ b/simple_syrup/nodes_v3/load_mask_batch.py @@ -68,8 +68,8 @@ class LoadMaskBatchV3(_ComfyNodeBase): options=list(MASK_CHANNELS), default="alpha", tooltip=( - "Image channel read from every file using ComfyUI mask " - "loading semantics." + "Image channel read from every file. Missing alpha " + "produces zero coverage at the source image dimensions." ), ), ], diff --git a/simple_syrup/runtime/mask_file_loader.py b/simple_syrup/runtime/mask_file_loader.py index cc934fc..422a0a1 100644 --- a/simple_syrup/runtime/mask_file_loader.py +++ b/simple_syrup/runtime/mask_file_loader.py @@ -11,13 +11,13 @@ from importlib import import_module from pathlib import Path import torch -from PIL import Image +from PIL import Image, ImageOps MASK_CHANNELS: tuple[str, ...] = ("alpha", "red", "green", "blue") class MaskFileLoader: - """Load one static mask through ComfyUI's native mask implementation.""" + """Load one static mask while preserving authored image geometry.""" def available_files(self) -> tuple[str, ...]: """Return choices declared by ComfyUI's native mask loader.""" @@ -38,12 +38,15 @@ class MaskFileLoader: raise ValueError(str(result)) def load(self, annotated_path: str, channel: str) -> torch.Tensor: - """Return exactly one BHW mask from a validated annotated path.""" + """Return one BHW mask with zero source-sized coverage when alpha is absent.""" self.validate(annotated_path, channel) path = self._resolve_path(annotated_path) with Image.open(path) as image: frame_count = int(getattr(image, "n_frames", 1)) + normalized_image = ImageOps.exif_transpose(image) + source_width, source_height = normalized_image.size + has_alpha = "A" in normalized_image.getbands() if frame_count != 1: raise ValueError( f"Load Mask Batch requires one mask per file; {annotated_path!r} " @@ -55,6 +58,8 @@ class MaskFileLoader: mask = result[0] if not isinstance(mask, torch.Tensor): raise TypeError(f"ComfyUI did not return a MASK for {annotated_path!r}.") + if channel == "alpha" and not has_alpha: + mask = mask.new_zeros((1, source_height, source_width)) if mask.ndim != 3 or int(mask.shape[0]) != 1: raise ValueError( f"Load Mask Batch requires one mask per file; {annotated_path!r} " diff --git a/tests/test_load_mask_batch_v3_node.py b/tests/test_load_mask_batch_v3_node.py index 117f216..7648d9f 100644 --- a/tests/test_load_mask_batch_v3_node.py +++ b/tests/test_load_mask_batch_v3_node.py @@ -52,6 +52,7 @@ def test_schema_exposes_native_ordered_mask_multiselect() -> None: inputs = {value.id: value for value in schema.inputs} image = inputs["image"].as_dict() + channel = inputs["channel"].as_dict() assert schema.node_id == "SimpleSyrup.LoadMaskBatch" assert schema.display_name == "Load Mask Batch" assert schema.has_intermediate_output is False @@ -65,6 +66,8 @@ def test_schema_exposes_native_ordered_mask_multiselect() -> None: } assert image["default"] == [] assert image["options"] == ["b.png", "a.png"] + assert channel["default"] == "alpha" + assert "zero coverage" in channel["tooltip"] assert [output.io_type for output in schema.outputs] == ["MASK"] diff --git a/tests/test_mask_batch_preview_routes.py b/tests/test_mask_batch_preview_routes.py index f1db509..99f8e3a 100644 --- a/tests/test_mask_batch_preview_routes.py +++ b/tests/test_mask_batch_preview_routes.py @@ -198,6 +198,55 @@ def test_preview_route_uses_real_native_loader_and_renderer_on_synthetic_masks( assert preview.size == (8, 6) +def test_preview_route_preserves_rgb_geometry_for_missing_alpha( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An RGB file previews as source-sized zero coverage for the alpha channel.""" + + input_directory = tmp_path / "input" + preview_directory = tmp_path / "preview" + input_directory.mkdir() + preview_directory.mkdir() + mask_path = input_directory / "rgb.png" + Image.new("RGB", (9, 7), color=(255, 255, 255)).save(mask_path) + + folder_paths = import_module("folder_paths") + monkeypatch.setattr( + folder_paths, + "exists_annotated_filepath", + lambda value: value == "rgb.png", + ) + monkeypatch.setattr( + folder_paths, + "get_annotated_filepath", + lambda value: str(mask_path), + ) + monkeypatch.setattr( + folder_paths, + "get_temp_directory", + lambda: str(preview_directory), + ) + + handler = MaskBatchPreviewHandlers( + LoadMaskBatchService(), + NativeMaskBatchPreviewRenderer(), + ) + response = asyncio.run( + handler.post_preview(FakeRequest({"files": ["rgb.png"], "channel": "alpha"})) + ) + + payload = json.loads(response_text(response)) + assert response.status == 200 + preview_record = payload["images"][0] + preview_path = ( + preview_directory / preview_record["subfolder"] / preview_record["filename"] + ) + with Image.open(preview_path) as preview: + assert preview.size == (9, 7) + assert preview.convert("L").getextrema() == (0, 0) + + def test_preview_route_renders_mixed_dimensions_independently( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_mask_file_loader.py b/tests/test_mask_file_loader.py index 256e418..422b5c8 100644 --- a/tests/test_mask_file_loader.py +++ b/tests/test_mask_file_loader.py @@ -68,6 +68,40 @@ def test_load_uses_native_mask_loader_and_validated_path( assert torch.all(result == 0.75) +def test_missing_alpha_returns_zero_coverage_at_source_dimensions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """RGB masks retain their authored geometry instead of Comfy's 64px fallback.""" + + path = tmp_path / "rgb-mask.png" + Image.new("RGB", (3, 2), color=(255, 255, 255)).save(path) + _patch_path(monkeypatch, path) + + class FakeNativeLoader: + """Reproduce Comfy's missing-alpha fallback mask.""" + + @classmethod + def VALIDATE_INPUTS(cls, value: str) -> bool: + """Accept the annotated path through the native contract.""" + + del cls, value + return True + + def load_image_mask(self, value: str, channel: str) -> tuple[torch.Tensor]: + """Return the native 64x64 zero mask for missing alpha.""" + + del self, value, channel + return (torch.zeros((1, 64, 64)),) + + monkeypatch.setattr(import_module("nodes"), "LoadImageMask", FakeNativeLoader) + + result = MaskFileLoader().load("rgb-mask.png", "alpha") + + assert result.shape == (1, 2, 3) + assert torch.count_nonzero(result) == 0 + + def test_available_files_uses_native_mask_loader_choices( monkeypatch: pytest.MonkeyPatch, ) -> None: From 18b7a72ddf87018473f1fde70493f782c36d15e3 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Tue, 4 Aug 2026 21:42:03 -0400 Subject: [PATCH 33/40] test(media): capture native preview verification --- ...chrome-nodes1-cube-canvas-mask-controls.png | Bin 0 -> 257058 bytes ...e-nodes1-cube-mask-controls-before-drag.png | Bin 0 -> 257038 bytes .../chrome-nodes1-preview-controls-closeup.png | Bin 0 -> 8345 bytes ...me-nodes2-cube-mask-controls-after-drag.png | Bin 0 -> 244011 bytes ...e-nodes2-cube-mask-controls-before-move.png | Bin 0 -> 240003 bytes .../chrome-nodes2-cube-mask-controls-final.png | Bin 0 -> 244011 bytes ...firefox-nodes2-mask-controls-after-move.png | Bin 0 -> 116107 bytes .../firefox-nodes2-mask-controls-final.png | Bin 0 -> 380539 bytes artifacts/firefox-nodes2-mask-controls.png | Bin 0 -> 114092 bytes 9 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 artifacts/chrome-nodes1-cube-canvas-mask-controls.png create mode 100644 artifacts/chrome-nodes1-cube-mask-controls-before-drag.png create mode 100644 artifacts/chrome-nodes1-preview-controls-closeup.png create mode 100644 artifacts/chrome-nodes2-cube-mask-controls-after-drag.png create mode 100644 artifacts/chrome-nodes2-cube-mask-controls-before-move.png create mode 100644 artifacts/chrome-nodes2-cube-mask-controls-final.png create mode 100644 artifacts/firefox-nodes2-mask-controls-after-move.png create mode 100644 artifacts/firefox-nodes2-mask-controls-final.png create mode 100644 artifacts/firefox-nodes2-mask-controls.png diff --git a/artifacts/chrome-nodes1-cube-canvas-mask-controls.png b/artifacts/chrome-nodes1-cube-canvas-mask-controls.png new file mode 100644 index 0000000000000000000000000000000000000000..1d73b330d9da9c322078b08954bf713d9d9ea5a7 GIT binary patch literal 257058 zcmeFZXIK+oyY`(x2ps}QZ;>vDNR>_^RY8h?XaG@BAs`*3DAE*Ax^$&VM*&MfnhJfNT97;h2qoSguq}oSEPXlA!$HvCGkA;PUTacH7lb?%) z1%43DFC=_G^Z+}r_+fF8!-66QM6f#nlarHEQBW~aQ89^duyBa{fBxI40WnZOzLTes zg84us3}8|Q@J>C58)z!=4}|{%lac_9C8wYSs?A{_Fexbs7)nJ(K|w-JPC^a_wk2iU z50ONYG4UCle*)bnC3Di)HQ;Wiv`b9p17>woYq!AKE^>Z>qZ&c6pD#S6U@q|e{2{my4}(ly}8=bZr%4Jod53?Mbo4oInQ-DX46xvFTXedW%o z_qAGIZGJC>ksZ*%Qzgf9c+Olh>&k7eOv)YUc3*CX@TP07?|`V(vbIs<`Y5jZ>IUZV zqCa17tEWE5SPyuRpAbY!9%8qU+@xH*ZR#l`eYbReLg-sIhv&W5t9&=cdF&+gLxMJ+ zS;ft`de9x4pi0Tq(_MPht=#L>S0mum^R#DC`vBUuuc%;r>k@~FsGF?S$)I}!9(-3^ z_~|0aMO62lEuts)b~1W&`ECgRV!WmCG}YG!@5F{yjg^#TD8;{ixRf=nZ`wCIGv#iP zT-;-iHc-gR$-6cqbtEBx59U4fO82v8pidOpn)cnLl+1z$mc^Z&eHI;v?17u1?h$Wt z&1T%yIwr+ul@i*0NL$o1nAmH!65=`EDu4S+V>FE2jCP-iK`Y#lN0?9$?jJBj&oSYTD!a%%eIY zYkd7g{%Y^|OsSP&`R7OHKvj<#x?q0Fn(OV9)zAr5t%(=pg43tBO?vI!-Ip=R*$*Zw z8{bkRk{OcDayx2Hpm>h7FJ3N7zg^Sw2|1ROS`yQl*2Ci?aWs!_F-No{$2ivGQGN@L z%ooL=S_j_K8s5C$uT(EVtZal`FF%i4rtMxl8*5b5Bd?Q{mpv2p6CvpGsB=a}cE+&Z z=wV*ZA)Am9yty_QR_aPMy>{C}Fb3AlqK;<<&C00{?T4%iCl8@NlvP6D zKROw}3^0cO_widFc$!bAe`Vgfws<-5^{1)9T5evU#Yz6D3iX)l7a~hev?%6IH(7t| zr-~51>MzZ0zeekn>xVI;Q0x52_|vJm$Z6@8add@Ggo>`WQY^*?wPb(Cv3UK`=P$RD zok8=g-P(2cd`hUMUum{n4=cG*QtK<1Zql-u@80{cm(KU?<;X&9hDdTH*KlhS`E! zyn@Z$9nk*85+Sr`>ghh&9`p>FUD_~RZdlF@-vLqDKSyTtb~p5sd3ZN0AnOR>jZ5{KbqJ!=zkXHtyH@MqEJxMY+ZjvUZEP9Xg(HU?P#51 zXT%H+R;8#9N_)fkS(wjsVqSm2x{l7g!;bgS!u3T@vBmUo@{_Uyj=9{Za|uf~W2-9r z*HU4AIm)UF-)gt~{6E56ON3{Zv=d5c3YttXUkg9FPv5$r{u+7T``OFZ&A!l}E%F_Z zrW3U?$AW9#L3G76vz+lQ$NVQf9*T)j*|(rp33>Lm3pP(~_-)6gF)q^I{K$?L{i$>+ z^5Glz&9*EvZSnl+Xz=y|Y2*j1Mh0^C8_8)M7Kf&`q*XG?ku$DZ&tu&&Ge_dQ#TwJ} zN-sB0r~z3#@oTKzS$hy0WAsb zfY!vmuRt*Ht&s6gzn30ZJ5uAc@u5|1i}Ka>@lD@=gA{7#_mstZE7hQoDU-LVpGr8q z+X1ZzjUVG(NqcNIevtam8I}kdaH_(ml_=x}@4f}{?N!(I9T3uK2Na!lV1#~aej

VjT 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 z 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 k_j6}|z74R-!KG$>@LlPcR=LqsD**hu| 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

NkQ%HBM&_b=+lIUAt@vML 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 literal 0 HcmV?d00001 diff --git a/artifacts/chrome-nodes1-cube-mask-controls-before-drag.png b/artifacts/chrome-nodes1-cube-mask-controls-before-drag.png new file mode 100644 index 0000000000000000000000000000000000000000..5f735d53d4c5b8b4637f9d72125415f18dc41754 GIT binary patch literal 257038 zcmeFZXIK+oyY`(x2ps}QZ;>vDNR>_^RY8h?XaG@BAs`*3DAE*Ax^$&VM*&MfnhJfNT97;h2qoSguq}oSEPXlA!$HvCGkA;PUTacH7lb?%) z1%43DFC=_G^Z+}r_+fF8!-66QM6f#nlarHEQBW~aQ89^duyBa{fBxI40WnZOzLTes zg84us3}8|Q@J>C58)z!=4}|{%lac_9C8wYSs?A{_Fexbs7)nJ(K|w-JPC^a_wk2iU z50ONYG4UCle*)bnC3Di)HQ;Wiv`b9p17>woYq!AKE^>Z>qZ&c6pD#S6U@q|e{2{my4}(ly}8=bZr%4Jod53?Mbo4oInQ-DX46xvFTXedW%o z_qAGIZGJC>ksZ*%Qzgf9c+Olh>&k7eOv)YUc3*CX@TP07?|`V(vbIs<`Y5jZ>IUZV zqCa17tEWE5SPyuRpAbY!9%8qU+@xH*ZR#l`eYbReLg-sIhv&W5t9&=cdF&+gLxMJ+ zS;ft`de9x4pi0Tq(_MPht=#L>S0mum^R#DC`vBUuuc%;r>k@~FsGF?S$)I}!9(-3^ z_~|0aMO62lEuts)b~1W&`ECgRV!WmCG}YG!@5F{yjg^#TD8;{ixRf=nZ`wCIGv#iP zT-;-iHc-gR$-6cqbtEBx59U4fO82v8pidOpn)cnLl+1z$mc^Z&eHI;v?17u1?h$Wt z&1T%yIwr+ul@i*0NL$o1nAmH!65=`EDu4S+V>FE2jCP-iK`Y#lN0?9$?jJBj&oSYTD!a%%eIY zYkd7g{%Y^|OsSP&`R7OHKvj<#x?q0Fn(OV9)zAr5t%(=pg43tBO?vI!-Ip=R*$*Zw z8{bkRk{OcDayx2Hpm>h7FJ3N7zg^Sw2|1ROS`yQl*2Ci?aWs!_F-No{$2ivGQGN@L z%ooL=S_j_K8s5C$uT(EVtZal`FF%i4rtMxl8*5b5Bd?Q{mpv2p6CvpGsB=a}cE+&Z z=wV*ZA)Am9yty_QR_aPMy>{C}Fb3AlqK;<<&C00{?T4%iCl8@NlvP6D zKROw}3^0cO_widFc$!bAe`Vgfws<-5^{1)9T5evU#Yz6D3iX)l7a~hev?%6IH(7t| zr-~51>MzZ0zeekn>xVI;Q0x52_|vJm$Z6@8add@Ggo>`WQY^*?wPb(Cv3UK`=P$RD zok8=g-P(2cd`hUMUum{n4=cG*QtK<1Zql-u@80{cm(KU?<;X&9hDdTH*KlhS`E! zyn@Z$9nk*85+Sr`>ghh&9`p>FUD_~RZdlF@-vLqDKSyTtb~p5sd3ZN0AnOR>jZ5{KbqJ!=zkXHtyH@MqEJxMY+ZjvUZEP9Xg(HU?P#51 zXT%H+R;8#9N_)fkS(wjsVqSm2x{l7g!;bgS!u3T@vBmUo@{_Uyj=9{Za|uf~W2-9r z*HU4AIm)UF-)gt~{6E56ON3{Zv=d5c3YttXUkg9FPv5$r{u+7T``OFZ&A!l}E%F_Z zrW3U?$AW9#L3G76vz+lQ$NVQf9*T)j*|(rp33>Lm3pP(~_-)6gF)q^I{K$?L{i$>+ z^5Glz&9*EvZSnl+Xz=y|Y2*j1Mh0^C8_8)M7Kf&`q*XG?ku$DZ&tu&&Ge_dQ#TwJ} zN-sB0r~z3#@oTKzS$hy0WAsb zfY!vmuRt*Ht&s6gzn30ZJ5uAc@u5|1i}Ka>@lD@=gA{7#_mstZE7hQoDU-LVpGr8q z+X1ZzjUVG(NqcNIevtam8I}kdaH_(ml_=x}@4f}{?N!(I9T3uK2Na!lV1#~aej

VjT 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 z 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 mfxJ@1O|gZ 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Ⓢ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

_u$o>YH!cZRjlg_^vv(VF@}>6%P`et# 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

L z_|%Z3o1p{ro4NO{TzFw}al?vNKT-3vKYF`wh1P$n0CRmV11lG%9$eh8n%!0ZFHTg`s0KhDuxtNT_+crc;Ko>%srBphrI%|G9HkYY$7R>OS^BFBY-X48-UXvEi2wC= zo?eE3kTc^te2u!zMm}#Z6#<79mcHmzLEzWoN4)U?S^|9uV@rQmF1iGErX!fj;-`g9 z5SP)?K>&RtjgTr|c5`#Uvtj{_tu0Qi@hGNP9Qw9e`D$1Nd>izbu>|WS63BM+7c+fA z?aOXtXkg)=x)jtd05e|36X-{P2{31tUO25G_IRx-uS}4VtC}yw+cS^`Ic_NCyXGe;bF0e1Nsn=h&txqO|YO$ossv zBjD^UmD#}QuCQg781s8Id#;w zRZA$Hss~IA=-SH*KPbm_a?Umwzm2OdmIc-J2;*%Meu|2`XRmfTzGcN zGI7wrYS5NBa0?98%T&<+pdfiKaIH6gz3x0joMr8Xeh)5=E*SZghd`|PkJLEZIT)@t z7iQ&Z*X704eKb2Z$sq5oI~2=JfZ!Z)|Fg_&=fn0!_w8>FC3=4;H!6*|tsI$q?R)qS zjBMUtSabKYn+t|rb0=o&-Y6e^7m+pm6ZJS~l>8C~EezA$tbaUf_BOa*0%#R)KGyC0 z_ArO>h|>3V?&2qP!)IwtjK@D3PjIZTgP^Re*^lk_o1qW}}u|6+;tJlYE-W%@uGWQK3h15U%d*)|Au0# zw4qSD;VLFS@a^6Hdab~D2WH?MAUgMT@k{;Xl%gd7KN|S!JLSIaeCp%>6Xkw8n)?bQ zAsj#lb-r%#0{Px=1bG&s~ zr|Pu*L(b-Mj|m-3+BJE`P^`UbE?aDH?%KX#=})@z`F;`sHatW|SsyUyyZ_m}^@@U@ z4OTGOyZc2k!}&TnZ!%~f&XVWDlU{r>JN2l*sk)?Z@Zr|{EnVdgxliAv`mEOEd*3g0 zeZx6YY519J!l=}?`HoYH{I3~e{Wn}G6!*eL-=mRVq}z9T%xLJKa7id1$BYN`Ig=p6 zUFCLPO(l-rCA!B^WwaFVwnx?K(!#fHyxG|={y5@R&szOC3fZn|0;4<8DszJPgg&Gv zyNwl|WLwmAD}C#++3bCuy#kopt6oLjefoXWkLKsf9z9pP=r4WVu7t%hhvM*GjS9IM!>kq78 zCZvQIw0`^mPAYd!r_Uv_4g@^jvGPi9;V?mmfti}!4doz!vP5;a_8en{WIlGx1S9mx zB2r(?t#>9tTIP&;U&^R3CFeq+$f^T-!+_YGnd_+;epz{^wfmLR19&K8^ER-)D6$MY z>{{xQdFa7MQd;sa2`E+pHOK*r&F(-fc7=c_&TmcH)qS#(vL-xgIl_PkPfQD(c?8T% zQB1nd+oZEGR@jxe2AE1^sc_X8nP3e>=TwEmPEaXSRqUxy9{CM%Bp`Gv#ywpLSsAB1 z-w5_eF^7HSnKqd2mG}-qTEabZkRexU#wku{i3SJgF>SOr+5*OfxnEljevp=wWLYX{ zE0_lR`co7$yAttm2gHdL0s@r6G>MdaqTWD@wuCf($6NjnXFc4qDMA~9MjN0CsW?g^ zA-nJ%2{1R=#l%)XKyK^H@x%q`;glBcCCCZ9Xu&+w7m5)912<26n6`m&B@3MvI@5ny zt-osqCnFUxvZ=(u)iX6P$BRdxGHZiPt_5dgmE{Ho#uJ!Q&29$#P3Hz1%@}mkkYe@} zhep(fY{j%=zyc095!_}fp%meXtl$I>P_BMixjki+Q*PEB1O*dHXqskpyQHMc>}jkf zrvnz?x~v2eEFPU=GaAOp38Y7oQiAm<3Fa$-sZ=QmB#6X-Fs|r@7xnN{8aHE4OmQ{R zA|E5S^d$MM2?DX5KEd}jbegzRrS#<_B@dv+O@Qk3I4B1M z0?_eNU6dvL;1OUB1As|R5T1HZ_5z_@Nxi>qmbYH-9a@jE!XAf6#37jv3w<*2%CV=C zTdjOB<>B{U7c(+nk>la`32MCdX{Z#)ur7G{XzfaY{1`jbhNnh{`nqXq}SRNQRIIOq{l6B>$Q?4d9Xw zo;VFeSEZQ_4M0eyg-(c*q#mYP-U8M`F9Rqq6l6;TUUVux0~Vs8>BQ8)6f{N%YneMp zrd-}1VT_|GGG3|nu-p~9JVHp6%GuMIZ5a5$*U!H~QM^sS?ov@jeX3 z7}l1smskc(pN361+hYUR6N)a(J}nMRm(KKyuufJ8KKEY`3heg1HmML8Wk@!NDQ#xX zXA#KvfKdi{(o5ke$YKK_7j!vS1=gb{V;L4^*z-~EOnejxdaN&|EW=o52d|6Xe!+8Y zH9Gz1wpg3%i2Jr(w>X{)z%s%`|1;yf?4u4gBym_M3FJ=sE=^@)x_lNH#T_UYOw$C zr3A9FL~!1Qo z8Ftw(_lKVJq;o3G^Z{eKSF)IR1HIP`zuT73?D?9H#v}l957G=PX{FG%gMf``>)Oi!tvy|d zz|mGJz+im8CK^_AE(m-20?lTc(zaxYR5ML91XW9}1ZWdsXE^8R4)0?TT{OBed8iYoK&juO_d3y$QupCBJ%Yc0 z)2caAt~yod(&aosMfTP*R9LF1!Vw(ti zZ3$s3N!zx}UTiwNq6Aiw&W`YLy^e!iVTR51bKHOd&-OWdKM6{QJ0cRZ;Eu^X&o$Ll zTR1@ulc6&lN04L`8oM;YVG*60&S}zsNs($J0GV=O(;!}m0LyH3 z4D&37nv6H<2>B`hWx6vwUIP!kE+t65;dlEAQa@F-2I{WWWg1*KKK5a~1HzW;{X1I< zQt8>2%DF{aI_;)1nz03UcJhQgxf!p&rak}5rQ)c7s|(_swP{Tn7q6~AQ5Enp{AOv= z!)lHGYrE!(x6z!5nVd5!M(f{fpZRh3$8@B!c=<(hApP*=*7VVw=Sfp~6`!BqvKiJh zNaM^2+VV6$c16iGd)oVKGsc${wLkK8)~W2C${@WCml^B*C_2+)(WWR&61IF&I)5s` z9@vPFKRxEar;&&6#2$@ss~CC@KC5QFnAvpGg|&rjldBpaJf(k^+&Cr2eI@4PRAbt` z=d{j=14Gi0^FFt@_Phv-zxFPoo9lLH&dJ)(M)w7V^rmbhuUKYpQLRzsJ@>NjaK_CF zNh+;h6;p8Dgox9RIXM?;e&ffk$&Od^&($rS%cL}t&nWv2U9@)GC`H(QMepg<4S$c} zPn7e4lgI6b=()u!RR)GG+$qOzS&-Uys>pXf?ZZ+Vgxy}WMPa?KxlxUcOK2B!cT&k1p&Pu++X;_8oISulCKq)5RqdydilhhEd#~*A zyZY89U6rDK=!rE7)t`FXhkk73=Y3VL9O%)Vg2PsCd;`+6c2{e0Z_VZjeh@`$8NeSG zTN$Un`)kAT=aGAEM7+BCygaK$rl#^%CdJ92r^T-(+f7qV!}!BK4Ch)22jAN!t~b=} zWFEYdzFl?bXpr)puFXTce#P8Bn#OZUW;|SlGW;)T?<AlbjUe=Xj@@9qfF5KC#q}nApi;k z{jyu-3Ex+QY%j7pr}sxvxmiY!p3fQS?>2$i7fkUJtvs4->w5xTR6MLXc$^XL>3Z%; zKu}S&b5Tv*&DC3F)`z@2Z*z$A%CUu`k{fI9uZ-0F%4L*S=N+u}TWvL_6V(=VJXE%? zp~Cl|kD~qYzUl#1iM@{d(??s(4tYGVR6tFBm)P@%DX>hq+0T$`GWzKh`HOkElUtSK z(bjR&>qB`L8AjYU0$J4&G`;yR1=}gZL)-UX310Eym67s|N^kt|(S!%okGb6xPM_xo z(qGOc6&PoRx#T=pxOGHJ9ILAt*F4(bL*T4?#S&tD3aNFj5|?~WX%>q>DbjRfTjQojbo(OT zDe0-cl1^cifS|8>Zf}fiexBd(o=Ca6ECpZ%v)&sXJ;rwXcMHy5;qCX+KOK1O_uFx| zt~h<}+1=u(1|=zjRxDdR^2FC_8UO=~K*-K)+#zz6P$AG1wXK@gzZj^EQ6VB~xt`A0 zQ_Ug3BYHVS0%7xww3W|JyJl{DpF)B$@0*@?;+6rU>5%)zjdI5MrF6)_M+Q(Kq^;3x zvP>Koh3g{4(t1LI6D?wR;dUX|*Vv8aeUNrju$MCj)iTaK9PmC*s+TSHHhC^6@yiaGEoL`?qjIKpm$IXIG( z=bDb0<`_xl+XFvQ)x+08&*N(U*CVovlz(AXf}lzd^Rb(SW}IXRs-}~wBUD;0AorL^ z#bT`UbaZMT3-j;AB3`G!#3GDAh>jLf)#2PA-l18FO6XJ&k1FG|+#sy6msp~pWdZnl zl6FV}kR8xq+ZeO4wbnX~mo0?qw`%BI$c{HXfGOa#oT;xd%iAgd&&q=R4zjmo-m1_9 zK}{8brB=KEx=$q0!Gan@V5?!o2F3mnMN9@PNdZY6-N7xM?jpH|t4%K~1{?bntu)m6 zatG+cQL9Yv_`oS5$gIPeA;_%b&~4WAuo%&?OOqO@LoMsv;RVj>C}M1u^j5G5#g*<3t^8%h(wZu}RF8meTia_UC-vGs4?OQ#IMu5~(t6g;AhbJxywrj+ZGsK|; z5_?v*g^aNsjM~8qDTFW{GLk?_fME)Bqb4{Lw(4+Mf`s7-9SiPy4+;R-w}!t~w*zU&TpnpkgEXe`6Y9kT98s4bU6a(t=vI=b{!>-}bNhzwn1P~2#bl>cc(Z&PDV z#K$KI)K*$|m}7!{1!~(;wdCcDx$^nbN)qw>B&yGGQ8~H}H84#M$}vG`HI)K)V5vd~ zwieoo^55lGl$6(Lr5XH6kX}h#g=x91y1-iJtdSU*9dA^>Y(Bi0JaU4^&i_AgD;LbZ zDloa4Vbl6Td=p9OlIRGF<_TdMv}<%JlF@{A2q?>la^qx^@4Dp%zNCjQDkQpNjT4Mh z^pa#JimO*+XaLH{^Rr#OXfS1xgOVd#1d=VR1Gs{HOH3?(%k@%05=@8yFh>-^A_0RX zc6ZjJ#wEQ&;*cU#{O(9r9z-a6CS0lkn|!AMlE%Bh0zeqy*~7j@+^cZ?aps6b;EtuL zDUMpdg%Y5!E&!9EWLOuhL^4JzA!)G14G{t)q(`h4PLU!#$oghw?C_wHmjX4GK-RvE zBh`>o!-VodvLzJibtG?NdLw!FlQ=P|U_@N6y_APs$IQJL4mi;SB-+^`zyii>A>aNZ zI|>P)B)F>A%BJ46AlZ`WK1Bw`yhug~nRpG;D}2ZwwWtmLO?rh=OlDF0C&W8+A(zhu zr{zWAg;y@<>;x}yq@dLmI4l2eGb^HFb#!j=#RbgrqWJPXtFID3y?W`iW_~?ev#J2& z>$I&f*SaCR`4SmpM_~E?CS2FuVcUAnHAosBkh`JQNt(dmk}NGg=71>y$%&p7z_?ff zldaDRaOaJWNv7};ff6(w!8Bw+v!0Znvcv^dsFtou4QZ(hU9B}{nL z*$0Xdjo5cHU(S4(-n?gIn>uN7lg-A&dmpC{GM;{+UhcjB&~N2%uO2gBFoHTIV}nI2 zzYE>C_k5wDGhc(OrWI4BMEk%e>GW?33qEi596Mb7bt3EH>GsFwpF-1Thi~3KXzR4%GM>O&b}WZ=6@px-xP?UWc+$Z*Tap zrc3=F0W%5pK{EE6f^RCMlfQ2t>3M!XC%5i`@sPfG+Jff@bI0+Hr}k>)&(6|0KY9F! z->Efy3imAG_?|Hl%KZ6y_v{;g=+fQm&V?tQIwwPSdTvC|PE`HsXqE*m#PnF@Cwy>K z^M%hFb&MFnzO_~RxluRB-`BaOeoC4xp&W}{SDhN^2RO{9_JMcFERz!3dcBN?=V=^4 zCF9l}WWI&FfkTW=`ka*J(cQlNVOpQE1Zlzs8KEyXr6^@TX(juqX%b$p!fiTL-8ZkC zp(k)LL+>SH@{g~4QriP3b902l$1%G7(%xFrGu^cFJehmqp8sBP;o1G8Hjm7`FI{Jr zzIy>oapo@^c_@-2Ftz5eFIBd-=!8VsjO?Q<7w5Ns+)9}ve|b$6Hg8o{Otw(SC7gX^ zbiBJhDg5j^-?5|W(#72J=9b5bQ}mq!CT*OCZdj$Mo-y3t^>wBR*cj(BketE9t^1gh z`{vq6QaioTZ{yYQyuB(Ehb*Uf0;wuXtS?F-pJT=9hS(doxpUjya4GZ@1-ovpKbbc# zhDtZy|Gj7KPQdfrs*1;M8aw=sVXxE7YV$c=h$xPa`?w_6)4z!xj2&i7_{o%|MDCzW zx+h(J#9$7U^}e8gvGJF2$}Z`bd&FmgzM1O!a*wd&`r!!8Hz(^p8+B&ubyvM|6?L!n z)IFB9_f6qa7VZ(#a*&&oXwLqC8m#HZArZ*nZV|CO(0ALUfg#yQ?s@_cSjR_`Rx zFzfF$ogxBzmsmIVgzdxnIG$4fiF&d6M!`?i?13jQE1y66cIUIsiSHEhWPYl!G4E+P z^f`>Zu?5*_V-5N>kLH5@Pt+$Pe>tBAD8r^TEd@6j+h=8IT{3+Xo9FIl{Yt(}?H`J) z)840h+WUH*hp!SbXt&;j2kXP1tb3P6|6@Soj7@k2;l)ZKP?aK;dI-ZU++tkXxmhvHu5Db()58BXUQSqTDgV!~)YyMZqfC&D<|iLr-lUTAur3i!1rmd0`J5 zvI54e>Soe|2ibh#<#Dn$*LL^}#gACl>g7%Go>eFcOD3J>&>e7|Vp~zY#OLr@0qnOT z$87Vp&LvO7%6~VuEd3EOq^}sIJiS|+q>joINo(=5<*aEU4y$YZ?ytesW25XKB${;2 z;`AehB4PP3QZTxWQ$F}`L6jgtLWI_iTZI=?c637>XBORFv#&_etZ(_~QsiALJ0oOZ%TRissgB!H1nwrk-wAD5VsZN3rhtDV&gReR%0<;v?y zD;bUE=<(6cC)G%FO<&~ zwU>-9mM2*uVkJr})mkWTJPJiq(oCv>&{K{&B|M4~7b?}^khY2+CGXACDUO~18*)WJ=V+DkbY9-8N1XNCDD~hKxK^0~-1sFzDcB4ueh#0$k zYhmtPBwwy(5srh06<{KPc?W(#!?Vo6#t~@+a!+Y^%>k+^h!yKjPN`m?Mf{5`3NzV( zN7-0=I-#ufTk>_~#L*g4*tq@}u#w#>>VdhFBdK7&(wD1eTfrGYsAMUCL6B(pAd?Te z`ox0B{{SSXs6AZGJZ~;xV4SIxa7-(Ul$viHD1(U$Q1oDkB6qb-!1cVkKGJ$-9u+LhKp&Uk>e5D!?ZWP%( za~f>GvyauTfUC=P!TeGNMIT$QUx5pWSwM1gKdvVPh$mnufq*^d1F0MDR1dYk@3d_n?K9taZLnY(P5Tx!w3P$PrXiH0JP zb6B}J6tc0UiDcp_(AwgK(u%jCgyH*C%P_zO3Vjz-6g>nXu zmo;N0N5>?isBET@V^hvGl(;`F<^V?$%2eWR5A2oKrGn(5UV3=t&DBj6>~N)EWPt&r z(eB5o%^S07ThbWzA<~+*zfa8UE^kILrFj(Ob#|Mz*W@OZ9Ho@pr4OI$YLVXjd7RdL z`Te%BhZbve;2sS*i#B8FvZZidgwRaAE`D(?jb#puY zlzcb5t=MwhM%&P=U-Pz4&;)tS8sLQ!7`?UpK||nYn$WJHm`HsJ|DhvYI<<;;AUu%J^57QG(4HfzEZjt zDN?pnveEn?`%CZRalSY?TX0LSB=_jZ?L_?_HVuXo2Tng`Up!!pyH~pKMXu}CwC#+w zHKLQB=kJ(~m^xjvJ-l(&M0|xXS~2PBddC`vFqe<24v|Nnj9VqWZ|ANyHRW=>{lb(i zB!u4gV7Fk)54WGFud;EpfqeGyykwyUm+Q%P+p(|Man+fg41$2%&}Wz;|Z6XpPw3TsHlE zY{@rifHn}1F~yjYOrbwqx44w_xrrh`~5oSzTfvbxBZFzSpfY- z4Q&ko2?+o|Li_{lV*zRa3LrTJIgo;aoB{-*pgcx@>=+f*u@kg($LU#4u(Gn8U}k3L zI?cn*!N~{jVh^iib0KYyYWTZrWL6lU);w$t35;8IpAdrfZl8hX9 z3;-e_o=e7fN&?6WGbCraUVB1P+Q|53Sn?2slvj9CenC6Gn!2f-HxdInX-mm>KBZ1Z z&g2O*3#+UdBCT-b{pkxY1X7p36GgKVn+1>^80zRlLP|z#nOG*pKr|j1DH({AoQ(Xp zR-|MMKw`VZMo&n>jFX0zd9Qorw@W2|H@X>C`+k24aGdO?ZVUh@U>~3~w)ti2rDs#s zkAce}Xlw83r+j@UfAwsW6OPcJZk(Y85S)~t`VVeY-noYw!9>Vx}#NhlC zhq8NW0BN-7r&ye;*L zE+z0^A8PvqiewqhU-2thi?7W(O&f(1X)?wg!x!|WEd`WkbF%}}*XEy0Y%vx4PPbHd z)FK?FQE*i0qe-0Gw;&CTCxiUw>2l%-t)ea3c%IW+ngwfmiBnD)a4Z+slh5Jg0wert zMJ^D@R^!L}0A#itII&B_pURvQO+Tti#qm7%qyq7wKo=(WY zYG<8hQ65vdW&wP4_r-=!BTKQQel<@_e-g~colEJ|Lhunyy+xt+)O6f3J- zc)iBx&a_7L0P1l^+30n=IN1N3V2C*!My+d^@k0X8U~EeWocd#6n1#c~Suojo)WzTHp83$Ggc z_zjEGN$d88f()9agH48~T1Lp7+yQA@*W zV(*F)6mo9cv_Lj>%n`+(GX>|-|$E9MdBN5YGvSJxHZjDSd(*pen% z`-PCZ`VE0qn~+iExhHWOXb)A*Z2oWw0}HJkgXPrJSy}eQRf0Wg64+Z%60)- zFC2%j`;?(goA_wyeu1-`)6|8FRSv2ZUGmAFA7|xJ+DO^srVH~Gm3H(eeeV&?wp-9F zVv44z$k0M%=l9<}J0SQie+;*#|_EbnDi9j7I*NQCJYcw{sp}1#e|Dpb751 zdWHU)10>8w5W^_`S+N zDb~EM&al^{^0pj|_XI_QUI2H`GVL_X$?XG}5d-^x^B((v)!dj0PW&YG7IXM zLGo3!%AInQM1Lh}aa#gJT0fWTQw;igj(Uz|AHZ_ZKNzupi8Lf<Nq>74EhL7iu9z^xli zB_UhXAv+O&(__bdU?{D^+)m^ryZhONh*wTu$V4wS6jT^aS?N2JO_wBMD*a$)EcWgPu52gW%z|7Ffu4Kiz!*fq5UW zN%g0+$c@~U-O8chQ{SyH^V9jGm&Z{OMWI)oM2es z-^|MLf=3W-G@kpz9~`a*D>T04spw^MYL^Qovqa$rDkaG}Z*Ng(O0;3?Q!p4@$%JCS>s z$fj8damKkP;J7LsBz?gzoI`bTy`rf*%nN;ALn;=UAP&b2L|D$oIcmd}4wdRWOuYOw z=`Ze7g;C<}F-$1fE$?%s?!4Ybvc)p*?>Hwm^w_Nl4$pO;M0Y$+lzo8cp)FO81id;u z`7bH&rxcakCd;3c3_Teu>E5XJrX+V$Wbq&`LHD4HigfIkO0MVKPXiSaY~FD|pBZm6 z9VS?mLR9f4{>IJ|YqBO-#c+}!5%+inPiqSHkK3rAI)yHvz)7>?H;PS z@uaCjGH38>SX?KPlkWnk>z^K2^`%QOk&SvOvTLvS=+3jg@bP0+*Cp_mO*Z9VBLhgh zN6`81<|^sc`nB9vb#9(R>W=WU(J1XHaci>f+`8wz=J()EnP)h;gdpC!v6*UUmF>yU zsZ<`6QGxSwlMI&UG1H%bc^e^z03MOp>^QdyqMr(|j9X|dbm~2;L*bYn>F*MlHi@+? zUmo|#wsYFJ-`w{!fIhJGp9<(WY)aKNw)9@w*6DrwTBbsFf?2su_1wzyZMqAKri%I{ zLL)wUh4w}35<^L}Wcn_2;`6P~WkYV3t_dC{#9jAv0W=zLIfHBBWnZt=M~2}guo8r- z79E00Zv(>B#jK>e8EaUiv%s9#HmK{Zn74VP{86GMC0KhEC9IM@GPs3)7B*$^1)%&a z?OpGRLp(ftRmkisS;xe?+G6yx8av1D;7yxgC~0 zxw8~L*Sh?aKCkx4`_b*8S)OMcDU}0%4(4;%* zh1$t<7^rL6t?*6Kt5O}D#t1(}=GxM+9GdUQjsm?;7VBFs;nQ9UZdkFSUant@vWi|4 zhr7p4T0S-{5tcVAtsS0f+`cRS6m7|4xdi+0Paqd3{iW=cd0_QI&qgcV>U|TQDs`S| z1P0kO2fLr-vHPdm-4PN0o&6uP4MUaF~dD|AI3tEQ05zul}z~4C7&fr9**? zjPfQ01$|J@B=px+SNkwwbA0aj@l&r@Sz+sfH+J9GZKpwNj}n3M`WvcYguLycCzF1g zcLv-#CylpRq@D!-x&pW821oAd_t@Gk}+Y#k*wLY9-raQYis2MVMdwt19j;o0F=-7=yN8k&t@#nI%lDG@dQt6S{%-yj*_ImB9qi2mGr+-Fss~1w%FMJKLDrz<-&v@z&(!595eC z{gnHF3;K&TnJ$HB?_0LPnjRfmYh93~D}`-$qm@-z<`j1p8;`7cmZBq8ws z(fD8fWFtbTmU#B;AF9vCr^-|-pQ>vgCSK&k!-)tpk7~}3s5JQht5Oe%{QrBazc37W z0i-MbSF7y<-jjkbxyxZ>xPJ%YKQU}1vxj&`X}K-S5bww0hhvhe|4*PwgnCLQ{$Vg2(mnubiQ!NX+&u}c4P-dL7;DENwMn$qglM?ARgA+qbT^dc zNL+mao)2xNa!(#kK1aj#YFC%^{1z~zd{uDCQ-28Rx45@!^9?q+tg1zH|Er%|9u1-s zUXwa8$~<*FDnT~i)Mg_FHiP@VjqDMa4@cS2%y&YCk{B3eEWX>-|6i|YOwga+hPIk- z5BePPegu9j4-iTWVkVona^FR@)9nL#*F1tt*YoQB;qd%rHDYYKn>ACuJ0D2D57=pS zSYHV7r@a>r5G;}n+y@ktt_dH?k7at2C|I}_c-&)jkFd+M{$KXzsO%{5FP%OrJ4*bs z5(~5-_}k>}R_nW(`6QlC+j6bO+k;pB;TL0q*1cbyz-{qs{L=+WZ$dBmW{zAY_l{XEmrgG4}8HlMDXSC8!sl*U@`IjgEsioLaPYq|0`+)z_k_NYkPho!>ked?vTVwLWiTke^928E( z>KC%G|Ea{QdokOsj_V8i0RO^v5@MwPlQl(dN-eS?+1t?h^4;h_dPX`ew-x9R@v03` z67u9LT)Pb#sN7vhdGbzse)oYxpiAz%uz#AA4oDI5i|q0(!KK~YnabUH#eb?gVElNT z8S2O(A%9X>p^MJROHw7;M%1-nH`lrHVj0Pz7sa%4cC<yJ>`lmuPebC099SfhZ+{HBZVg{!6byDX!{@@L7yb--OuYmam&-Q5XH2>d9sPiE*MjwjsDy?3_l~Z=$Q(h(Xz8DLMx8_;oGG$AZ zO-i?6fJFr%MVF$K;AA%H^$9J!sW{D+R9VVVzp;gQXj<#qe>m^x|@mII%lrAgx6u zvw=%XylEDwt6ipm1&oH~AaS`6_sIKmSB7c|={0YS+JP(!D^gvmus)C|p_y$ZJ=^97 zB+CWc)q@(H;7~Wc4OT4F?m35V5dEPui^$CwUGoF-)b0c58g=X^8bv=*^w|_;KE^BQ z2&3c8_3|=BSGRez7qgXC;&#&N6Q-Yx&wpOrHXG^6x@6WvD~Nx+ccwXuP8*&@CMst; z$Ysle0yQ< z*z^i>y1u{*IH`Cp?kN_~Ee1hXatpZv%JCcmlXc@*VN*DBMoS(^+bpLX>8T*S)EbWq zfkji52xO((sHj1?Ue<)=2>N?dZg27)BgSI(b26nQivzf zRXM^Ju7@hjz)pn-s`C`?1L)Dy8AU4Q_-(Ux!!%$2$l@A@h$+EuX`7wuKy_91|NJ6f zEX4%CAqB<$0hct!%3uMd{7AlU*3z)`4u5^eg|1$-{Oqj~@YASnEV+t+tyEE2)#BfzK&Fa*O((1hRf#A2f^`IJuDvartWEBkcbf{ofjZfhCedXk1>v#@L z9XJOVZdGP{T+Eq9X_CcpntFB+M^g~Vj44?bj*F~CD!!ga|z3@c97R1^MlcnDHm6KE3 z`|2cx-7E{j-^Qf?du23?UJfz-!64v@c@Yl3T9#dfKbfPmkaVj5?!)gI`+x_SDI0V9 z>h))jj*CajqDX<Guv*y>vhS$*Pa*GnBB}Y!WFg2-|)u_ z2(;v{QVee`Xgt|W(|}H&uC0GlsoK&l&yo_;-fm-4JZ)1LY=|hqVzym{7pJXUlw=12 zpT5NarJW>O?@OmAT4?c|KUr?L%57!16p&Edb&=9XRFKU#G>G~_)+@0#GI}JuauwQ3 z&BAtd45CaV~s zqUD65WKR$}`XwC;9R$PpVinrz^Q0;1wT`NYFhNKG34F2Do)9M*QXr#z;X`T*GF9-6 zA26ymdZ5?mh+frDzI%)jvy`OrKTMu7OIwftzdWD`vE3`3dm{Fh26gT0OV@3dRp{v? zPw5Ug04e7s#J_|kyOJZUR7>h;>ds6tCE#F^JrO2V0sNUS66MZ3CF%1agPI!7BqhX2 zE{TQl_q{z2@O=+26touNdTQy^Gad^f(M1nxOZm};k?ixd%t$`tc8lQCHs(K$$+XF|q1foDW<$qSJyC4z zIme_sXW1BXj@2I)#Cwup25)HG0*7|K0&yCa2m)T+T~@WdL6#s=+g{WQOC%OajAcS- zyCPY?>ZV88olkwsj)Ty4!Y;bgAkw33!Fj)nHL|SE(p@*pLRy{9pNJOfrfAeri{L!L z95paZjPXB}j1z+-vVw(Qhj%?)+aa`o`CEDMvQLQ-KG#Xt{B;Xjgd!g&`?8K&s3Nz} zIEjHgib2W)NYCmsD06={rTHu`!nB&?{mZ;ihZEVjFs1_FH(knX`b;kbje|V@LHeY3 z*lkZIKxPC;(Z(mw5mump*{&i}q+#f%$oF(2b$Cblh0NfoGzn2EL)RDLvjLIx-eEL%!ia2h&RPtz11b5vRDYM8tg;cvUu_!GJLIy;WmAT(>K&5NK2MdQ z)9bt^kc9Jg#g_xHDcR1A2m(|Yx*w284uUB^c2-zD<#1{rk7Xh4icIO16tKxSfKX}-qfs;C6B^*vbwx$@J$S`zFW-6nfEwH~_3Eej47^}CanmKA2 z8F`Qoz`ww1=sh<>J%7+T`t8aQnf1kUqkA>}$B_-xa5ly72CUBX$G?9#NlTgu&r5b# zIz<+07nzr=aU1*#QPt~3s#2ZHs-Eg(=WdIP1I9@QwOP+49uj->JIg+6aXcEhtnrlM zDKnCn>yk|vmK;x{yKb6rKr^Mh zh;(~0$)XU=FDi*Oep37B5ZA&ZJV^$(t3Z$ETUuU+?9p93OFLxHJzCPDc9pCn4#p(A zY-4Fp430qsc5AUuw@`!X>Z*#VYUS3|@e>1D@R2o#vB`P%l)WEL6~e=Aq&Gi?70`(+ zrAe9PId#jFO=?f@)UTq*EKQ6S8r#PhVR94;7Q9p#t14^T?c@7z&?~YS7NTVMr zEA=+6bGbbU>6_*l)EaHXB#(7vbW0I~UY!1pzHhw8Fo8?NRhUF$xn_jX1ma+S!@0Rp}+zp4m`uedX^$T)RcjOsLUFcXY5VUYy=$t<7JQkM^ zy3$^v>n1811R2Ha^|F4Zek_WG5xWuMF)P%26IDA=A)j4#7q+Tj4qd4p2)KDaGjsLB zrpnuKn}U6Sk9&Cuud=)f-Ci_-XAiUwXxs-Z>F+YkLwAVriO)XZZL8Y(q$=xdfke)A ziWrP`iaPmi-a6J%YZxmsh81>8m9>DE8x?CPOg)vRbP_Zt&$h6FNH1j} zJ`(N~QjWQN_FYeN+#OyGR@w)s`xE;>whx#NSr`1Xe>QbEJ!DNSIv$==C_9~8FKAY3 z<4C-DEE(4VGq=Yib&At)#koAXEVMYfz=C>)9u}BA&ZrRcA{B>9)3A*VKIJm%D*W)$ z>lVj_o$)^f8_w`6(xbv^lcq#kKeS##y9MZVSe9db{jthUS)ylahx(7T+p|N?blFZr z>AQRyD;$|_)?|iAZtAS~=Bkg`mG`c>GgK~Mf@1ihYF;9hQ7`SQ%<3!S;9OH3i6%Nn{eoiY^6_g(+@ zuZ=~GCSLlhVex22<5SwAE6;h(#dbRtIhh9uDpY$PXB_4YPP^lRc-dt9n(zH)f7*m) zouyIg#7FvDCufT~4Br>VZf7gcx$RwB5}jLwo(nQNN;`kGD>pUy83nA-XjeIsA02_O8^zN!IbQ$&#~}F;aEur*l>Jz3n#+x*PQA z9D7FQdfM0EUaKrSw5DN51UkRSnMAC`cH)b?bxzW}d&>N4;tg_!CcKHurNDW3N=Pv) zM~Q^gNd9~HE$_hAKZI;0+}%965>=$=XEz@xoOKT){gItf6fBd3rZA| z)<{=_N!i#`D-#2_hVki={8!h&x4P3STTYl)xMNDj~?38i7k4B z@8~eWxB9-r@a$5x(6hTf^nsq>_5|_}5!lK@h!@D<`t3#p@j0S;4x*fA4{KiibAw^e zaO>JVg3~?#;}W~d%S*SIyI56+xV;Zx+)*k*PiHv3LJa)b}~O+Ha{S<+g_a=B*P$)Cv9V z7-?rrrMcadFrKE=M2GY8>@24|lFeqK(RjD0#T-7(QSXLY#o00xk5bNUj`4(GnQBPW~u0mK7 zIblf|_NMzQcheO_4_Vu3;zOK=JfD>*c9|`FkB!L=d36o|o?0P zR{wxpmz>pm&hk}}t_aOlS<<3wl#gEID?(q2@_;)S;dtnR`%V>sMv|=t(!oxxJvul7 zNDpzZXZIP_Q4Oy`hiXhI%a|C~;XL(%lkznk0n}5SSv*)cm)^b7@kvk<5p}VyxDpPZ z3RJGosvDH%K0Qhm%J~7zD8rdC+hVv=#3i$Ll2FW@g7d{sg9lP+%j<@#P{-0PaL8N} za7(^q|NCE_m9nt~q z2l)yENuYJ2lM?)VfKvFj&U-@KNB1(m3{O~w**opghU70*i+T;UC&&Lh0$Ew!7BqXO zlhaVE_^y-72R@f=d0Wi?UFX*TdLQCB#EfTO1DL87VgG&F|8J92UgbPnZzoo2IQ-$o zaewrRUe)sq*O{Sa1~ekQ7mepQtx}uCXR#2<*^%uO)-9ys@ykF){g1*S?*3e+m1qbpUcPwasBcz z3fBEg9G>3mmt&jMIe+_m=>Je99mcIoUKDqex;`|UOf1}U6-GTYQ4JDL>QoFA3r@4>~nEeGzw!?^ZNtdXPO2Y~yv7{z7XyvmUsUvU@5P`>1dp z%BJ_^Ufzzqt!^hkqMZ{K%|NDHLDTqJ1^GSotI!r*mzf^;&Oyi{RUseFeh{elm&yDr zF#dV=mBZW$Mru`3%8?(ihAWzb!IIfOo9lc?0?_1k(n3*&%bXkZ|5iL(H|jpa{MIMl z%<5-_;FrV|{2N-2snrMK6)WQX|7ced68&Z&rHNj^$I#LzH3;-kc+8N%bNA5=ONPu* zg9{NB0)fs$6wN=y{45s*|IQ>~W^l^cHmCBazT5w{=(tPJDXaF8YWcoSPisy3t2!47 zLoMMgMh8psWq-Qk>WxmN>h@;lQc$xXx2v0c@wig2M=>17ynfSK3z62_lv)Q z6Q@N>{^ETn->)C=uSvs34|(68H^vph6?B!j#@%r#&{H)qJzPP&>d8J}g5b4%;cAeD z6Qcru!bRR=TG=cO`Hd+UlR&;}l?|9Tiz5}x3LA>_R1DP*St1t9u-v04H%lGa_}FHv zoF;*()SSzoUm<}vuBEa!la!1&B2RRdStCy(?s4SE6Q`OaS9z~HfCHRJmeVL=6Fu~+(o*kUr{!3B{s8iy>fH>)pSKp z?=WALr&C3TF4LPt&6|6%Ed!4$VlW?+1#FnGgf}jADz$W)nos3CCLeyJIccH27P&Q( z$|G0_{RGv;Pm7u$95u4hO%DKR~LaH13Wr@e}lcuf_PSecUbZEkF`ZZVr>xYW6{Zd`m4SDz}{+;@`hY6t1KOc(GWH)lrMg@9OJKd)Y5 znBL`KY8o{6V>^nV7~tN6roc>mT?~-zt4Anb%5t2%WYB)TzE8|GE5UT|D)yN2wSsLid$LK_pWZf>5= zeE`q$_Ou+DrgCmZwSHT*3T5&Va8hi6=Gy{S31C^OHx-2HLY@wxc(R_LHR4VF@X`o8M+`CZb_&prpRi{qeb zwS9V#8jNtl`6xUG7i?y@_oe8)@vW&elCco zaT9{!V9^eV>%&0`iLX@Zb))dBYw37M*_2+fSHJ@?&r?sd+>%JmS3`chi=KcIbO~KU zwQd2{1DGb5j~wACPBBY{Kz+riaIURo+>VrF%zX11v;2;dnWL2C-oT2V2ku_VjIfGs zZW{EAtm3u=PR_)Kf`If*?qcq?ScvDBN$A3Peqrw>`=qcbHwYCJ#dGmJ!8f0-MXdT` zFDY8u$!4g;mDd;bUcJGbED#_s{?Vys<{OXt{Wo8}rFO6E15^oqdV!gnQY%db!TJOb zEjOP=fiy^+vjbM&iKb+_qWMMIC~(SBS0=+#OfY;WcjFlbUb*6t-zZzp6`M1x=~QW2 zXB{J>Hf~FsXJZ%jdc5Weje?y^G(4+@&KqC=yjDWgMcab?^jydj#D&GASflN5ZJVs zwhZlS)DXExRlc|8(e@@9dNTp%o)jP=gS})l75Q?w>pcih2|Yg@<>cjbV#k3!{vDfB zj3O?vc47K`&N1Nl;}I0wG^qn(D3BSE@V0x%+jaE8nFSotZ_isI(H$79Sue)X1NzN1 zT_O2BI|?0?jKs@EY8n(@mfM{^KlZr4xHD`m(J)m+CEF$5`Am^D_ian^nH!PIvI)sp zbm)7FW=&EHJ6My%nzof0QL`##hhc->s~s<38qwET}QlKP%Sq717mrHOT2HB5BJO;wVS>>z9$Pb5@!toS zH&f`W6Zcg@Unj(EN?8_JVLs%aoUUWOqPGvodc^(h>B$WsY`3cQgWm->45fGxJ!)(p z#8s7MAry0Zw4Iy$>au@YdPG&^3^OA2{6ZO^G< zcA>^wT{>BH?wLA|@iiP*isNLKUW10=6t{qj!W>i1%Xy;I3Fu9?nID`j?BMO(FC3ty zOIdq%DLWsp?e^4#yt%pIgj@=F=*II*)?+h4|3^G>U7&9tAkq82fbQ9z*W=*1$=1&b z(0*CEw^?#K-QstmRlaO?wQr{o-+;J3r!4VUb2p}LAMjJoBGDjjHqX{V4g|sD)>Q@$ z1byVCtJ{Ou27j5{2M{Iop~c^BLy3~^Mym{LqF-+35?@%mPkbd*cB0jrD1oS`BzT*z z7|pg1s67y6MSRubw|X`-^g#nhD)tmZ#9+uJaRMr!6ReSS54>Mmke*!e2Pp0Kcsvy?!0Th z@}j4c6{R7SmjCP|KXJu-z=gGwR3cO8!*L5fw*E3;G(8_1{=7OR!`-!t4;T(tE%1#u zujNs9rZB|7Su&gvta{h6o{Ao zLfyefn>mRs+vq|q$MB;HYBw-yT3#|-3q_1sk|DfOiicUD=oi$NhLJ-2aawo#D?}(! z;Zkk=(Y(iz=OmF8KjUBOM0GZ%yS`%O8)-o{Z-g*|T;AIJ*WR3`q(t(1C3FH|N%Gyc zRS=SK>12)LokAGkTR~Q$guKw3f|PucR9)agEhf*0c0z!I3`!8MXC*ak#@RETB;vZ0 z5?+iU?`$|J$*YH8FlWq*M-Wg@@e65+6V4>q7n;HmTn3w4!H>UPMTO34B`I)@o2Ufm zK2jQofaO`JI7{f+RXzE&9wspkJ@i7!iMMv8WCq#@-fH6!-eHUOP z!;F&{V-_b{L@I+h{Z%b`L9%39{BgbHi0}`#9EumoqQ&?rnZkNFVP0U>hqM=;qFU6n zh!Q?vHfc`P73C#>pC*F!K&R2zPvUESXRgA$AoL2lRp?)<)W?#k+V1id_`%3fujRw> zLu9G1Ne(b!BxZ}OVEouE_|DsNTj+h%QFqeKiwO7RJ0|iIlh?nH`ID8xXbGpgj*5RF zL#ahvoX#1o|GgFsIfX`rQ|P`qT8sWkSXnvm5b;S%f0-?i?A*<;pY#wz+cyglajInJ zbQj7Yj{(Doxz|*Gl^kXf!`OG(_CT`>MZ_rgWg}a*J-;PGv*K&u0T7ig)(5lbqcC^` zb5<$E@6~76-Gj1nUJQ}ZykGo?kV3$Bm!%3Q%Xp8+bScrRvr*i#Ct=QH)VqUF{e=iA z_|-$ZTC?=fuMf2OC~O&u^AtZPYqj5xpl6^^|Hu_9@|&EW%;X&)0YQrkJ2pJC{aNrD zYrB#j5RhnlfKVurrDKm#Tx%JWgRC0N$QI0db~4JJ2*sj8@$dOkU&spHVpKW~mi|S~ zP!Wh!J?7Qt_+R8O5DyP3ksT1isVTF%CAYh-p^_qH6t8ItVNZW5&9sOLs+{{xl4ymD zl}3|`k@thonLt*)DI~6-V`s)SQy?yC3+;bnx442kW9kbx9nVg^I36~yn0k2x2QRO` z%I6sR;oO6awKC#XCb2|W&v({zI@<>Bab%cnLqu5Jot0&Oo2dj`P}$S;_=mLCG+O(B zT_I(pQw0#T+w)$AxF`FmI7cJ#+|z}QWEn^!;$rqiVoRlDj${oE#k*z7IZWpL+u^q^ z*(fbhcP(X1#Y}U^b3SKJ+6QbIPK(zwHOhJ_`TA?_18}g#QJ-iI`R+aTjJ3SAjZ0TOcH4t4@`xxpM4o*e;EiWxk*MB&Ws@6n z5r6#=U;Bz+Px%ih&VbM|N<6D8M5CCvkZ9R_bL8{1F;CI^>Q7mIb}Yg%yLRFjV#|Jd zAs<7wb@zyuOU%;4eL&YfU@ddE@`oO=-(h5qpnLO#x{U*E>A`NZ-*>swLyk><^ila_ zBRM>act>0b-4)-N-&TIsCo5UQZA9Th2u-EX?=!n4Aub_D3XN_SVzx~vInI6GCH6ZX7gR9_ znEVp6>|!&OBWJv1`a>{mH~_JuMrhr#8qVFA*&Bw|YX(-~8l;g8oGV4`o8o;r8BwT8!mohOs?{WgBTk%cwK(w?fF`wMs2M$EI8)W^Yu0_x6*L z#`2YZ9{rsGS3+wFphJ}L@RC^5G7D(44i{%6z`U!6(zq_hg1Yx zSJc~F`80a<>XNdXKVfExZx*j7doFuZN!DMDNSX$rWrTKl^3~!bj*dt* z=$Gw&HGYe@1# zUv*3tl%sq)FD~L`9J{u|zhiJ#Ril^bknP~xGEltfE~H+zE{sFL$*UV_yf?yK^RvsE z=4J0`y&~s`X0no~OH#ZgBaYYq5mbSgBz7nLU<8{ux72G4mtejqsj@_shzt`qUtVGX z89TvcpL*5j*R6|M7Ohph@sTs3DOyxOO^j*H!AZ^UcHXB~0B;jxwKxLL>0TkXY;m-Y ziH)UWUdN?T=h(1_5hnp!fnx;1z1LjTxmqnz8h8$|$@8kE28bZpjMTEV zaZ_os11VVUakN3PKHPoO3>hbcPJ+-}Cut)oO4}?V&(LMQ2YAkSj&nXF`e55SwPvU_ z6*nyc+UZZ1eGv6w)w-eQzZ)i?F0L)^DQunOPDg(M$k%91X0oM4@??8pnqNsQJNT9& zrRsw!tmrI?@2pWg*U^^%47_8p)I2|0wBSa|l5u&dTPKP*KOo<4NGTb&XHKRb&K9^_6=U2!qH{&zf=F4Hn?n7JetHt9$=p9HdJ=br^T&Yt>8P~#V?T)Cw;xp#IbcxLFNZ}7b-atPAZ$5CdL zMCP(n?3K@px<9Oxc5DmvJ(3=qO!+DXoP`UF(zs?>Rg2kuk0yT!dhbj8w;VxAbt*`q zMN{;Hdy`qH5x6YVs5b8~HoV1g?=E6AgE*e?vM;4%JYLz7!5%a!3W z;hRurU##IOUEg1B7>bn2lT=x~cYYsmm$0UCZ*m@`(*mygY>Y#jK_>^M$Gup*}#J$iB;_&6GQXy66%il$zo9lBYa*V49Hm$9G5 zI#yiaSag5tq$pm=)Mass!!$w{m2Bb2mTf*>z9+gkT4q>^LRe|$9;d7R+9o6GFcL7S z!x^_Xi~ONXwZ}>Lq7C;!TZ{x!J6$hepPG%^>Shk7!!RrJ?C~J>uo$dZr>xwt!|>(7 zQmYTFR|W6!IY;ht>;tTn*;L&K^9Zw1LzI8gZEp)Jdt+lg_7_c{je)^!az%|OHM}`{ zYK3wb{!Y4X;;lE^^dXYaQ;qI9Js6+0>?8v1i`O1Lat;1@0gLeGE9Tv}gh(9sopQb0 zi*7z>a})TRlN>CxFL!l_TO*i#Y2)TUyD8DOqv6uH<}?mt*D}rzfnZbcnk{ImRccQ# zupxg1ovVqNbQ&$ycksEG`%Mj&k$6p$gc3^e^YPX{zNot`6o;Moaxu+uhbd%p=_>Kf zX1Vla2=JQj4-2?W`QGI#&rwNEe58&K!hhbji#$bhIPtON{EGVykDO>B5O^MvEu}&p zRgVro?x0up0W&s5DUqpI7^h?D1n#=A5c*dR+>J~krH|9X0t=&%5;XCGSA{xA-p;!N zR+Z`&Nyf4RDGPJ*tE}rj*lbj6uq=(0e>&dvRFtuNvrXQW^}Hc!N)j4a*?4ZAe742qh9ca6E4z zH^zK)=S4h(#-}ZX62#WlfbOraZMSZ15Cd2TxTkMoO*{ z)7#FzvtZSB!y%=-uqk`<^bB`W;PDg3B8#!1VVt_f=P~#;)dT^f{DW$mOu`1JCQo|4 zm{ibjf(^RB40n4>KNZEnSeed^NJIneBn`}&JK#V&sXy>@lO5EMIydyPxC%vYC+S1% zfGZrx`sQ$1^~xdBl@=|R)V>lg8!ypllKVZMe<4~8segRDOJC-=YN%s^fsv)1Dg{o` zAbs^3z~f=&3&vkGM>JFd$O7m3>8XA*%a3jYPb;pSf-JNFW)!3%(kFl%rPiHH%<~RD zY4&Ia3fy?@&&L|3=@aS)C4k?|Qd|5EPCd+E6r1f2AnJzBlKf9{h0_lR^*nn>ERjVb>aaG$TBWw6iR)&+ z04C>PdQQc4hn{#CzO3U2=A%|?zjeAUks&gF_BrF^DLkDK#KI((5H+&n49sC z=ZrLUNfYsqlO&#}Q;I&Z|7I5W6Hqlqy9-_i+!}P=AEHPrtO>+Ia2YK!P(|$s{ItvK4l{?Gnr=@m}!2c|n97p?Uo)e&4H8bS_>jCDP z@TqlG=a6Ax+3Hp}U`$A^Jv|MkRNhThzr7o#^iqSd%Blk!TvE%5rZUiwXO8XID@$EWm55P?$-alXAha9%+ORw z5An+T{PYJMjsGhqS8^F(KCxQkMO?c#`n3?aIQKQ*Z68&BU6W82bb@>KvHz-y2&;yW$Pmdgm!nXKhB%gv|;aH@TQVUoxLB|N6mR2$-XcAYLkoWg7ERP z^PI?D)1k@E0T;L{=k^$&p{tA_~u1B2_HeWNd zG5G4j`R}PvNcCxrd1_Rt+x39=kNR)9_|-|>zWH7H$pqW_Gkw<=_P%&l_Ob4Z+YRB$ zv(t@(*J>|u6$VGssRaq+3mdO&>{GIO8+~zd``pQqS!<=y$=Y3&lTpF)#x2M;Z>T5l zZ&C+{82>9}Vvjh1BlCMtT(e=nAN}Rx+P_5)|Iy_PV_w_3(@_y$+!|tPx^~lxCuo%; zUoN~zIRAsa97|v0NS(=LAWgJxT~U~Bk&(qPQh{k>1h`YsOtEm+L)3ycJW617G`a;tUG3mFSX2olbv$)^kiIRl{bx(NJm~dam7n=MP4s zU!j^hM#PKInLN$3ljoUormv?By@~(Fr@V6UR@eo9ZQUymOdXBl1?~zXcJwO7v`_q* z5JwTc(8*v;iN{6GQm8^43N9>Di0_qhQlmfzv z|5)pQn}?>tC{9^(K#f=@&wY^#UhEa1WO1x6-tKXg>85jT2K?R(Cg2DIobF5^_Pi zZXV3JJMp+7>bjD~6X_aoN?C^Qd{IAj(XbYBIO)mLD2XAh*J0E7i?m=lo{9=L%oXE# zBT9ko)CNFIZP_%z^GSe5vul)$HR@e~9bZP#JUy_DlBti#0(s;T)y#?P`RF=&kB$a( zl>kXs!2~iOP%u)=YN~zCTPQ}n5F|1f?B3xFNL%dxXT_8_Wxeh35p9S*1SI)IKv|yz z7AKg}XCuF=ApfS8c1Axj-F_A4^o>|b0O1o%8;f0bKzu!~1u~l;#3?x6e2B{TGp-e! z7FvGWb9R!U_`8Q*koewl6&0IU!kLei$=M})0UJpKAXhiEn5!?9{t+C>Et zNTA)*IM0qxbsF1DG49Z3@$9;9g^&{cYwMJSgp1sC0q4Y(*px;C^DT*_&eJozUZIj? zgHo)H?cQGoXP?{>z4E|Y0?-)=^ehPl(d#n~ z=pRuN7(q5?$jCxv*OWxFBEVSv4_|(Kk(<5MXABXZG}kooz1Oc-8|XJ$B0D0e{}5@) zfK{}hZ`3$s5e+AJQKpkXtEcj#P6}i-ojKFD4aS)(^^HuZ<2zEcf&e~fy6vlyz{!0v z>ema93!eS_eoZfz>+SoFPle}?xe&;lcXlRyw!Zl{t)$D@oHxDK7UTI=5y|hiJT9+f zZNiY|{~hT`&oA7v>I&q_#a5p?)34#z)_0-j=*0dZliy>H5M#{xzn7ff1T5PT{8K29 zmz6)gtTU=U5BxIvo6h4~%6~h19)=U4mc+U8!Jd|t=?bU7=ZPA>(U3-6*`MuDkhge7+q&mLo2zOuPCuPhZdzc?M zQl(l+wC>q2?khs+i3hDQ)c~R4r{#-s=yU}%fqgzu4u0S|Fa@b4Ns>m2JcF&_XD9>s z{(w~W>6w8`eNG)1jhJUxPSW}2547u`s&5Q2$_4+lnL)br3ba6r? zCCbR$60K|A^UNS?oijbrz9l@B$hk+FrgY*Q+@lKG zPp8n0Oc~Ev#BQT|81suWuAjfxzafb};3o$5yo&-Zy`#q$fl(Fj=>)n|3sSYGe8!{= zl0;1&Pn-Liu}>!zEhKSSMx<0<1XP6KmuMdKrDy|gJwuHNK>R$G0G#7+r5cN0TdQ02 zftn7tnTr0@~Gz~m7Jr!22@KvOC`CGDDYvg_#6 zmTlKM_5*gZe9FSc$#!$+L~;kzi|+e6%w*dwO;@mso!GWh`lbN^2p`KcCsmZ`Q`eNM z(3;V{l#=A>{B-R*6OTzrGm=Yv&q%g7mO{})(oAXnb-BKR;y^!~eWyQBAy(QThY!H( z@X&>Vx!StKNe7@jkw*#Lt-I<(*yn7pOc$EfEsaw>U|3o?W$o}DI8vmTLcL$Mr?65N z-}3^*!DY&u6;?Ql(cl&F2{<2$+cs3_vu!G7deZ}%EZEq?JqsWN7=^~yMhQ*pH7;6C^PSWzAri9`YF;&}%EGO^p#(ExI}G+AEz0}Y;Sq0gFf=y?|p#~nDJ z&m#8p`0%V-K`PY-{Gg#|1^Ml#IUotC-D(Ot41|ZE~r_6Fv;-iq3k}xEl51FtsdhkiZqu-Mopq+Z#81-Y3-p1E?9w!$}9!cg% z@*&ZhZ=N!smOJ7!#5mtEzc-6D?Zd-#CbX?`Q8a~3DkNA`ujm9h@U zsPItG;#Xeg92QZfBtWr2)Xt8ISHJH+#C!JLT=}U2H$9LJG~Y^4lk*?ebCiON6f@Gn z57sg;+AZVs@}2{hfcOGHko0snE_kRAi+V&*0hHR|)piPiHOS$SQA58%Gj$A)HyJ0M zzes5Zm!r7i0r|oI9;1NZwL8C|`MOIMkXOn19Vjrc-<=d^WRSq&`??f*?@*=MIZPG5 zoz)EOI*Dpv5$xY=c_Ob?^yP|xPw=9@+fYz7ys$czqwQTk{Z8h zoleACQ#aRPi#h#ER})Nc_o=YSj$6KdjlTEcd+(``(5Tm+D%?M<4!S*DJ=ECj-BAF{ zVH`NIx?sHO%}uv?SMAXW&(7PIkLgDE$J9Sl+x#&@URi(C?55CX-Ho*S*S>Vsk9z6j zk0U-%-$k&0SL=_-f9S*I)OF>SuXlVjd!Zzzs>@yd=t4_Z_ki=8N_vbY^>)dkYM5B7 zc&h5El8EEnJkvh0m!<#v=l_NKaBFJky(Fam6>GvSK}uoF_=#$N%YRlR1Q=!>(P&g0 z9K5Z=>%R%oyf7Fy;FWX6Ey}}(*JTp1=X2zV@3`7fCk|OMNE7J9!5(edbm5)MtNAbA zx9-v~KouUatj8u!+5_3exV`mfF8(TH&f7sYJI=}@h}7vHn2yEL%d-_TOIIA}C@7_v zjJ6pm5;8(Y20r}(V_tN#9r_*7z;`4riQ|JJEe`RkYuCoCi@JHeceKcFa~^+(3;dCM zxF?rz+dgJ0xZ?TWbS-#_OC=nn#=;Pg{H`t-l-V`ywPyRzqEWm{(b1yEceg~op(>4J z+23KPVT}2gv9q=e7`zPM1AB5QYum?Ig@08&MX)!uV*>GUi@k+ z5#2(mrm5o93W0T(A;68Ka_&J{1NyG~7z= z?$PA37#c^aKtVGFwyh(dOn)xmqY4ikDHE^>9U0=BlO{?XVQOG6n(cK%CvpbuQy&b3 zfO2QW7uy5j3Mw67HYE=$0e4CCG~)U42bM}BERj2hzNJe5s~w%mV{pZd?vi_TATq&+ zBhBL$YM{~cYei+LwZx9|j)u3`rnRK(<6Ij#a2*?0iN8E)8Q53dq^A_gcsPfW6_yF# zyaJyFNwj^jf@=w<#Xi4vZso1A+UgZf>?8)KVRfTA;uKT zb0EV#LwzF^7xtG6%0_N&J)&|gOER{CYmqV0{mo-aCJ)0q5&^0L>0Bv#VevQgg-2Iv z?&fvS2v^pb+thZ~p=VX^B|PFkZOwJc$6jtdbM!@9X$h8eH7b=owvvwlr^Kv1=AF*Z zN}lUyO|)sPG!PbKi$p08in^`!+br9_c%>DsUyN67w#OOnURR%fFD+npH6GKL z4P?jAKVIB2x-D?J^I6}PmyrD#e!uqU-2&$}Al#z#d|UxYcUwtaW0s#G*;YX3ipN+} zP)bk=@h(VBw_EYOcjxnTn6_1D5NcY(azz_ko7SSFk3YA;C4Tm19V|?0U*(!}OU`_H zYVNgTL+KCT7u(M7D-RE=46t)udCestv9N=?>@|LAP1UOVt8<`2%;M0W{j&}N<9b*8 zXR1k;)V4vQ7GJWjY^UT*$F@bo(3+f3v+=u2$_du0;u z)sy@rdtJU^zJTP1ll2bLn6Ux6#by6$Y9HnYMgKyyg|Bts0jS1=Y?I&_u7+q$q!KjQ7u)-q;vv3jDuCCF=NQr!S2` zHzl~?CFUnmhHTDQT?-2v`vX=EESxI*IiY8~P}zuL>V3Iab=Bp;-!@#Qzn#=7x*1x1 z<@WQdxxp1v*K~^mHh+-B0WabKA%7*$e@ z!c+Z3wPBhX`m8JDqMziFhDB`9*>`3J@Uhx3k?vF?tT4Y_v~}?CV|O(tbsMJFE$@hA zD%{K<9wwcwKpRb7FoEZ4!fQ;qh#Km25Uxm zJ8mZh3bjfj4jm!F#7cdxUDD7S?s$#q7h`Imo_}x_;+q-_yu*PLl_1N2cOX?6N7(h) zRm>R=^L7e(6EZpUwI~HP1J9&b473X4#FN|Rm|SF1P-dSBo%74q-->>ahY_fTn!@M) z>^0(L9@1eOA|JfOm6iiYV07-$9)~m1?BZhrH>B%HR^v)w1jTk7yseN(0cJXMG7|Yg z1g}7g)yyjiFRjPIIn7x%*4A1e1RyS=+=*Xr!Uj5rBE27)Fs^-Xn8@1 z9)2bggCq7CjhBj28*vdGHm5T{R@U;Npf)P@%(Iq@CW;mYrG(f36MTk{JS`R<8`}42 zK~x7Z2tXu?3?(&BQ*cn{PbZI`&0v12-yf=DBL?js!SUr{_Jw`P{G(v}I02?>=$(cN zE@fj3{S+2y?gD^p5;I;G(vS!Z7Z@#Dp(_|92F_M&?leeq;)erNl%hdubQP}E{sOR2QvCnSkBEnab{DA4ZVv3=@z zI733;tfP#o$0rBaM70(c+v!+s+YlB&w!+Y0AV%%2@c*rBFPpa1mdWl01L+&nsorve(cp6Euqq3nkOuJ z0l>;FF|K*$89q~#hl2_a6w!D6hY6l)J5+4>#s3K!IN+f@7r;@ zD8hTm($l~iq<_+)g1v&9jeKsIp^J_%CyCJt`;tt4OZjpk3smr6wj1-`WOS%<1^PBD zW7m>$^OrbkYjQ4RP(YNR1Qy?};OjX1#=c*7iTdAYaj2n=?}^bOHcU@sszkW9He@yg zhG>K;G5Bs4F`Hkka}EqJM_`MqP_4JRU*r(}wYdSBANHVdb+9&IN6^;XkGz2=;9DK{ z4-wbE^3Z35&A#h7I$H9a7NFZ$@kA@vwv5ej zKX%yR%?4fH&E_Ko+d4u}Ot)8@?b%+6O*e8E?$m01c2yoRo9c@Q_PES(o5er8#E3wi z`TWhd?5 z$P`_5ovhyY{BG?D!(SvcvI|#DZuN5-Vnd4fn7pdfyl5k^!`c%6_K-Rkrfm5Ce z*x=_zx=$%za3>#L>|RU>x!903yLp&%H0pH{V(me%MHGB-uip7zHeLxmfqWLJb8F;k zkxfCy*UzgTb)WRS8$`em;ZUbpO@VQQLBphvc=ps-1?) z9Cn7#h}6L&Im*9Q1Fw6&tJPwe*lRaX=6LN~=W4{UyoVkxvNgBv)cts5q|eYzdFQW# zaq(WCw}0Fc1^h@z9v=CwcP;RvMcMq+CZjom@;qw_~L@&2ZC0CI1D0K=-ZW*Mqk zM^HoT7>1AS#yc>NNOaQJ!KQo%?OaVu8r%)@E8KnH{Ni?DdAm-Gf z{T=4(N?TvY_gH4$G}MTs0!}?(GgVHSQSjc+rE#|xqacaeAC%__N9)cw!Ju;3?rrS> z_tTun`2AxrT1rd~7F`h4it~M}n;J6G&=AdPIU_wbFp9rmJWFYIgz#tqyM$Ze^$*5444?fwv~VFCp}6%bh}K=yB5nWyP#w zdFSLQ0aFPfNJ(lo8b22nhD8ye@knv<7=6sf5&|p|$(T#WdE<^*UJeXlQz+C2)EL-3 zPlB40Bo~QgFw&@7(y)=j!oL3MHi<3BxCTQU56js%ma4?KbkW--;4FX4vtF1`c{VE0jIE$SO(%@-0Yr&Q z1Y%*Qq_`Lb$r=HoRimsiDK61Y__Q&*siPSq1)pJo)H;b25dBWq1*!~kiHs6YQy{>HYnnuiP#e2wJIc?h7|saP+}Q?1J#7rc@jxedq;&Ggu10)p zMXN|kvzoiuy{$}C2b+(m6SweDa03rk{$E&~12;b^b!{5?fw_vwwf?!krS9C1JqHP2 z-Sh-xQW@}f1k44z3=k8MA5&yK1?-*)40#|S0eeMZr^6_T=T9M)xde-=o}uBwjYJA; z9{sG>jVW&2)@}@6-485Z&xp;)#RIE^OLQ#LD+AiqV6t&PK{+|&Okfa>3#?p^(FI2- z{lJt&`v){YD%X|_8)q155^&2lRbW6Rd*O8|tq-zT2$ELm z#Xu=sPyW*eQ-#WVB-wLJ?*aq`z*vs*7>@Eg2X0bfa#;>QWi3^uqZxrl1@lcSEEY%< z_Pft`qC{Ztp#e-OfC(k~HPNsBe)E_8{-Z2qzyQBIjp4(JhZh)}Y2sU2fHrA81l(+f z*(!31LbfSagJ}@B1Ne-bNy5AywxfFsT_RE*J>$>TNBHz%C96frhmS>H~%f)`hrq^tAM&E4JqlIe*T8Gc6-%cW!SIjM#QAncCXM2wZOl ztQes$)bFV2!ndV$f$On)FlnjsgVumBRXa+ueg(kB^mklE=GR-Go00aS+Aa}FR z@A>atSI5Gpo9J(iXWiG7Q^wS|=W|_hKrl>VVihEzJ21ZLIp6N@y9LDQ0c3$ucV-Uu!ID6yQ z>w@!Hjb81UySU?#@rlI?CFk904!eg|O-&)=MZWeNlgs@gF`0UCTgQ$1hUX%Gz>;Ua z$IyOxQc)ZnweBM@@_cn#Sl87ZH9EpK-s>Xsb3ft&hOhNH0@VZm``3T+9_*&e+{5Vh zUv}pWHWN<2-g{%q>g^sAPwEbyJCtKleEr&Ov|vH2e{aaUSl4d2OPlcNrS&TW$flAC=Y5qt+--n6vi5(TdPeb$22C*RiDgw}2GnpMr7M zfOk~;0TsLH0%>z146ek_n+n0Q*GTM$`wPl9? zbjr(qI)qk*_k`u&Wc^)GQbTAlK@3=aF5ke_LJpfRAzd&}k|oV+=9nM)xl{vfn@@Jh zBW`5jv!@X7WNHL3#Fu~pPHDz=(Um|scmW~Bx5AQo%xW*-afO}lhk!}Se<3a18UfEs zAo^?Zy|(rePdvtp$BTZWCZ)=(yx?u)1tQKR^-S&?hj`&eauUN3-X(-dVo6Yd<1!`C zR0_9*M>k4V5CtuJV*{=optTO^sloV#u} z-l0IW^c-Y4V@JQj5QCJRZnsRW9JBL$EUEC2W&c`WR)dD_3~t$1Z`~ZrbHlmCWnJz!o_33KO2j3^#OA;5`X}aam>7`6P)!;CbOYbC+)Yt zK_13W^PiY4)h`Rnf6$=Iq;D*_Or%hdRWr+xldT|Bassqv27v${4tH)BN305TUBn)N zx3wzU$`XjJ#HD!IMP6WpQCvp%7!T!kw51C#lG-4xf`Hh@2p|@SR9lE~lwblOWTth% zA%cI(Q9(ezO!q(O(ZAd1W#ai}U&_XogiA#dA4zOE3xVvka+A7@?=shaqTVP93|O~M ziHU~>)AOY0W-t}YSEw|)oIDTtWp2L>{r^FUF7LbiIKWoQ(Qni#&7CJg;Yj4U<&f!L z>eFn9WF2T`A%wXTRl`3Pcym=r#}7=VAh_B^rW7!6B9CN5A=N41cgMFm0KpCdSt{5; z3ks@oBugEngoBpDD4~C)RLL8HV)U>=FEtb0$l$tRu6X6fTfpP+G5ekt<=?a1WvwAe zXsUt5ATL``^$tG+&k)~E=5=MCyzsp3pv@a>w^y zz7&~Vd6QGKcH9GN>(JiPi$?#&I+dy6Nv3@h9cjW3vgecZlV zB*?-$Yh%i6{j1>fG_ znMH@wcLl~>cR6+r|FXF;P^8rpc!R1c;=l3Nf8UgWQf)$9m)PVjS8La38Z^H!JKhFZ zrjFYLCIVF@*t0AqKaWCCH3U+9Lb9r`2M=hT<5*Lq8m)~0eOeO5Q{z|ESgNoe7_~B% zE2v=dL=wwWYL=%v=M9Yoy}EL5g*^=;XPWrEDxIdZ%=#qBn~`b06uy&^;uV}GHtQnXIO(gfIm9)af*7GhXlrCqDNkN=eutV7$bf zOklwSKzq5kh?vKwYw0M314+1G=d|WFMwqcST+_+mJ}kRRVTRRkT|p1qSz(h>1Uz_D z(knEDEfs|%I6>0^4-G^xGc=P2mN)ef)a+_%;Eh&0>5lnYkM$g`QT2dLGD8Ygw1vEt znL&Ex1k3YK+g&We%eA=#=D~^f!&R(Ko3BhTi(pxzy~+ z*Dzu8G#SPzNn+cuT0A8gEl~n*ACn|@NeW&cr-z#%DVQffGYIbUU5Txjm(;32=N-gX z5)|TPdL@uotFq+`0YYe4%UT%_I(!(AARqD8G+_cR$s93khYNHrC3M!_eAOdbeG7N~YGOhZE%nq4BWM23XQj_KCdiH?OX zY5hQp+3+|K(tRw^?7wi|Zo0*$fv|%IM6we5!I(9MC(}t`(`viZwRM!rff5;0M$5Cf zi?Vg#Tml0gCmuVXnFKTtl`0U7wfqD4T)_D0v3`*tK(g@KSXd1%6yi^LjKTsU8~l`o zWgODS@oM0u0l>T381$;-i z3j_w=H1N#_&FZjNy@Gi-b-Mj^q6EMj&qDCc2aFHGj1ync2JtZ2jB`>ie<0cV-nA}c zmVqN%xFBc`TB#&LDoQDow{*bnMu+EsQq`gJ$Ee|W6)>J}*8ocAltL3i&~J?*z69rH#EhdrAlvdFG(c4 zd&spH94Zo=Jh_@Aa-{o<69d&Uf$w-{#|x~=Kv=Bh`0n|cgeonr%GnL%;m@gYdgp=| zs{W4 z{?v8f{9;7FolQDfsv3rxLT=d{I21DlQ@JKd!}y`t z$cFP9-@kVR|NYtX$Ocq<=1od+np4y_HDV30cC@s^U7UDGj(p8wlVh_pMLTa?d%8vz zDN?yF32prFU)0+B6(ymBV8D_slX1ItMl~7sT<;^a&w{7O0Y|)th@G2Ji?q*TedgbQ zonU3zZqG4ErBa8Skgaj0rq(ZnUKRBg|NQes@ID?w_1K`E*L3DdOEMk5xkHuhKT*>( zHKfjqO(V#K`84p_*MgU{11hy4-|koSE@Tg;nTqxpK2rF;LtdGGMKa(Nn=<^~cLP!m z`&a&3;HYP%r2L1*2_s09y+QrTyYCXRwHU8zpP+WszzENNz2ZLg9JJB!bM+h?JDZ>3 zv+|_2`kWGM)1~})g zWN=FpGbh(n-uW(52>Q}>HA9*KrRvQ$e^=@C7Sw@b-9IU=Y{7F@?haP*2^gk?(PUix z3dnU;(xQo3xGSf9erEZ3vTuX89&F;O-JA7)r5V)k81jxLe$#xOoo{9|jCT$BL=zRD zhSdpnc`)%>B#izCEGc3)#9=Pzv-?-xr`Y(R^&Lq33nU*))Jl>cNQra;-KzE>NlAhK zlEr^U)!kEBvPBcfEJ((hX_{mVZ)5R}4#buBqBE}rFU8j4MTW4cFnfY zp&#_0QX+KHL2_Kc;qAS;*!(Bb-&_u%eY7$Ei4?Ic-+eS&soh#;<<%}%gG7FFa5oO# zdCV0ea;33dr<#9~m_Mck5$SYrMvYy}6mN)cuAP`JDO<>61*a}#T%bPl^$@Vr^Zq%!qS{G=UPE==(c83LJ zo)S6(?id7hTjdE`hb4bw;-8D;r;2pDrs-*@!3tMCbkz`k>L&6ns30@_mBzRNDoqVK z#k8{01h9lK|3Z;*p`u6(v9anJ0kpIN&qXZOtQnO*ZUYX)5W?N!;< zrMyP#yvz#~!W&nxJep?#uqOQ&!`nnNm`k!=sZ{sANT1&$xx?me+|_RY=c z{!z20eGJ5T^+!_ucKxfz_qm_i9=Z1sCv=0+v}4n@yaQ1tpUTp#_U|D0o9^g;8G`vv zkrNp`btSRry5D3p;|+PVd~W-I$`QAP7LVYHYsZ0gmDm3PGYqg>G>*>P)8`X$ep_rg zTn|YqCgjaI$$9AmZzX^sED8(re*+Q%dIdp;nQ&?D^XI@@E#qD~OS`P+cXq{8CD+K; zr8j(RbAIDj7h{okX_pWFGSYKU-!-#W_-1`TVNry|i)Wd=qzKyE*99*%Js+9H_(#|i z`j55D9qAr3>f5VG7_=p?etV<%P*l&yyhK~_{~q}N{>@ouays@odR|V-tUNu?tnTA} zuq&;(&S^u`ossEPnssk}q-Fg_vptX!z3ltmRj3JAxl*ud!xPY;4)Nt^cdu^pjHW9r zDLFN{%@wqzkoS++>Kl`+Nb~&Gltyf|vQJ`U<_JE8^_%K6OyiTZ<}yaJanDo;gZ+cP z1k!%XLA*%+LJoq61YW)1ZiRnCBWYQ~!i$G>RZ1R96hNywY>wZ#szVz8BIK+5=kL@r zgTy6(Ew+0o@F8l$^X*%=U1#Kvs6~eZ#hmkh5uUlaCe!VHu2%Lu-#-G=Kezv9CLkSr z$UnS?b>5&3?)Cz7sNe&uo*qra$slNyu3&W5;9I08o9Et_7k7f}r}hvy>Z(ppLRF>I z(Fxx^*PJvhSe*GFOodD}lxlUavSgN`J2bUbfQ3yNTed}2cjl>o+- zXFzkIE`RsIm+0#|D}la*m6}r~WNRUQ+Ofd3PVYpihMx<%@di(m?l3KrHmE^oy|%G8 zsNtJYU~pAOPjKxy_YXr-;%k)#lJiHz?81ZE(8fR`PbMUi6vhB?H@wb(*x77LZd%GO zNOSJhJy8jqbEVoms}~fK;#D=KL%;rT-4`eZTSjguyY| z*a9tBzoFmHzAdM!y^EHniFRIGr$3)xsA$`Gw^MAJKq(mK@MD&FgRYUYUBDXbUNS)n3oD6=mf&ktlYS~gJS2D1e@Jh@C0I=&MF}YMqLwOUy*oSmDq8XpDQ0T7Yxs%*Gz`$ zbs+!BrI5Hr!AYXR7}S|{DGsC&?CbiZD2ReU{BY#6^1kpi7p!{4WAqIwAI7>M-Ec{) z6cF-*WZJ}evg>>zJOO8I=zK{pBq?n`V~cS`8_&x>6nr9A2+Ywu2Zq!krbCCf;}%gc z0)l$68bBK9f@LpP214ILi3UTUs)6v>)kB!kJahEjEo8nc2hO@eSpuBKc~lPpj|0WH zrO1%g1cg76b$T&@$r`{1k3?6beN-7Ke+0oNF`lNfo9DP|BvJABUD95&zuDeCB*4vU zx!~-bApl!szK7G5jyU|Y)q`C?faHgn1IyDv@U(O$ck>|ju@!SrSW*OFyPV$+Y$QVwUo48gI1K4KL(CFQxHv2|78rl$7==;g#v=|vPt)up(=fTiF0dHhcb;7_Xc=d zdc}wD4_+LYUh8-}TE1U5O1mzMV^&eQ!TWN-HAV`#;BJGSDk zsw(ZR_L?-w^rM#_T`n0sVQ@Y2{Mu{uORFFHpKWTK>}2^mkm0TCi-A|cv9~kff51*@ z0u0YT3Q=U z8$FWq$nZw<-;X|j+S4hjc)w9kec1oZZY@C)qOG(}-v2MV&~52y+%9pgr}9W{?|pdq z0CX*0()n}o%?gIAg2B26dKzdac}i>65fuL#waw@98N?6Tg0H%Nz(Q&!?$XD##rq?| zx1JW9P*2Nz<@I|$-tqrwx452K;d3)Hd8-4-@S}mh@W#sD>WP|sb=&3mxw|7j(oX+} zm76QdHS%)sd)FYkm8zx|8SE}2HnzRc@d77Gk~w;ks+KHm8ce#w&JvT1vdh$(JA1#K zWJe`3MP^7UDA;yM6*q%ktR6~OM7e6ttfZoq`An?|^-t9`fEToD>t~;CYg~SyqN0p=)eBH6{@enP7fjpbx#lHIDn|FvASqRQE6(A^V_u7HuG;IY-umtT1$f=Wkh z5iD!&S4eOd!Ac#&QTfF%I}$_6HH5&O>9-5a9xrqEGD(hAN4X$okvVKs55n6XNOe>W z=#$tmPwzuHu;$hm+70PP5)lrLlBp3r`cl3O*57IXF;#?>Izogb*bDZJR;)c!upU>) z*2nk((mQIP9s{|e4mrzdOm{zL5L4%VnR8M>*F6-H4yp$1^A0I&&o1_7!u|Xf%84Y;??BJ0($wGEZ7?*L$be6fR&@{-m8vWX!GgfD-)Ttke&GP0CK*@a($E&$ zp=8elT0X8dpZ`lxKj{Rkk4lEI6~0Wm|09I5dtdLy^BI= zl){si)ONoVmSb627kA+J)unYok&_yz99RL2hqw))hOkT}qK$!z)wd?ovVP`)elI1DzYJEwNoV1{0aqgsI zKut9pm)dig{hnNOc`JlSonPTss;Pq|;Frdq=D(+4Gj~GUz=~u2iXl0)Z-v4FNjMgn%cVUusKkxL=R6MG1rOoW4+dXsow7#-V-AswZVBA7tB9ZyXdO0Gvi8VvOL=n50HeGICT!N zJ~R4}fbkHH1&jyqtJ*IPnPid;D}g?tmn{h0<*x&%Pj3--*n$G-0T5EJoGOd^=WB24dKU@9}uj5zUuh%Il-&^Oj@N@s)zlF0UN zpz+^HD3&c;9UT7cJDRtx)Y~3_4lpNLa3fg0ln)?u8XZKmVE;BHf8{3u4-%tVC)5WE zc0dK{>G#_|m#kaQ2(iv?69!+eH&P?UITc%d1i855FUhi|Qv2|(CN!OYppYz9eYEpa z2Z?W1SlV3o83}fqov8=RVb8z+vZG=FTXOZ|3vi6!ERRPuVwT1b~o-++P0y7P!E53D)OLBe%`QYcujw8G?H+ak7P_wC^zT9r^*#4AthL%6#| zF&iBpHCe2_sItWV?Wt|#VcKG-Y_ImVx{-`Y_y=(p-J&SwrjhigS0a_P%o86u7JtT; zvTK458uuJ&JdyH#@2Z{G7Ps>*qv|{M&9b8!X`+wNXM63xn)%58F!SojVw;X;WPr7^ zlB-wQ0d|)&-OuuZmujuMQplOm>`#~5eun;gz+~LgSJTaryZWQ=Ip)gc?ev@U+8JFq zD517TarVu=IEs1*|2DllT6k(QPR0ZH)>hE}HQ0Qee*^x1t}?5r_Of`J^J;o~=M3<= zfU+HNvR~6P<{xgbeW;vu`@rA^|oQ|KqO5AF{>Zy!!na(@;CUe_;;?D*OIi~#7bJeE3NtimbWM&6L_`Dq^x)$qB8q=YTKiUgb!Z~ z)4Q(zdS^n(szwQ0Pc9~47s=D8?BtEN+@1qh6EF#!B;%{o_cpax-W-dbxOy{0rtr%# zi%mrVmJTW~Z<8&G@BU{7_}_P}XcA8<1f=aK%GK2I(5850aP?TT ziUvlq6twckK?#(X87%oZJ6=)>lc@^1B5=c%!N9g0B5fxL|6SV>#?Xz;I7~;`Iqqm4 z0obZxe_&$`sDb5HDBm5*K4u)h z$=G`G4@?teb{k9}dj+4yodpbZ>!BE8NSYxR%Y6Qh21Mix1$+*mYS8G5S!_x}$2N8{ z;Gl>K_+mR~dD=38l(`=Sz7BS@x#>PT1MLI96cLv~Qz_l8!(6~VON6tvrg;(MG4;E3_kP6~!Albkuu82fF!4MS8vFO*{Jsm1R61r&%NuYak z6W&Y9T8Qh?lJGnC)eaC*X6Ye|#AyS}T(gTV!?dr^xtCGp&gGpreT8=3V_|k#+aJU_n_w8T+13Gq>C`5k2ZpWdPkY(m{l8^QektW9uU~! zG$3o#Y@LjuNfMK#)ZI=Y^a`2QADmdKG%3;+CaI5h!v9@~hj_XJ;TuvvEhNR$T?BRjXVch1mqu)XIkak^@?A?h%qLp2 z2?B`@iZ72){#4NJ*pBl7ux!FvaKwI`#=i|O4bohoO4n|EX|8A_ycsW+N|ri;LW9hr zpa9P`wl7Bf{6$=>S)~Yh5LBr7=%@&usAy- zj3eG-8IQA5#q8n-d`>J86b(bZBvStll?AL`4@GtIuCc~XU|GDE`h+A{jv;|_02u5G zLN6pa_pS>MiI;41Gj1;liMxqX^GIL$nlaKS`tAa zCGD%!_jc*uYeQ>lLEu)68TY@VrE9J7%lF~NN%PX10H~tFA?M~}h+Q%<4Aq4PsO=X{v%zc(z4W73B*RgVzO(gTASM2g*_?Vq+*U_c>L%U<3G$X4=Tb%Pkz z;&Ml`^=)vL7+8k){E?C%tc02{aD(8)w*HSUr_khr#%zob1aE@ivtH(2l8JO&o0#X? zQ@Bt~VHUWWZ$lit-`5v>-S+M_ex~7n*2==a2?T6T$u%c44VNJe;rFRSv497EjNTqH z<&+0S$}bDEA}9bib_oLu3nCm22@I&3!pnSbQpdBkO?{F2QhuzRA%*kaq%M@e{sQ^+ zlG5$!K~S~6Szn?<+rT$r0*J2AZpgmsf}`|CZ1NV@hgfILo(Z0V!UlE(Cogf^SpJy` zv=10*{%aen2F8=b=}^V|_!yOr4V{H-@CB!Tz*MXDZLa{7Q0k;se^ABFU{0shI}uYq z`_&^$IvXnIPoFq!_iItW?*1iDt(0?-YKI+&zarL;{aRc4^4AOF(RG!TZ5|;THg7pl z-?hKuY$KBnvol({{D8Qw#i#x-6OGs&SM*&zZ*ZX18RqWgxP*AFl(IgWzi4n^+-MRP zdNWT2yP4T?A&FXsDy+}J$eFTFo9uliRGbz!1J@hM8dc-AUwoq~3wb~EoOKGLk z#VO||T^AZEdp=`Tx~2}Tdf+raU4OIYGKa0fuio?ycnPG!LfGla7PYYIhEM4|uavf`&TVM4 z+q+fi!TRCvlQtS%jU3xHP(ASC#|h4fvuArI&(Q0u{ix09Qg`)P;5F3{&zfcc`)enu zr#+YI{2*E?;&%kqaEn#-@9j`K&@gt9_BP60ydJ|O*u^U>Dj8MnI-xzhST!Ue%zEb* zxZe_;^6BYX&h&-g1#DT{{7%@gW?c}ed42EVz720^vy+j*eR+MxL5=exMe7TLSMB%| zdQ)zo$%Sq%Q}~i1^*9)o{gcqkeT^j_;`}N&J0?9EN9g(w!g|*FX1+>^n$3!P|Jyc? z$9;7_nUZWQi?(gGYPmki5MMtT>Gn_}{lI|borxWnJtrg2J58KAn;qHNobbc%J3@s6 z%R8TPQ%grDMt~P->v(N?wW+t&v|=P2Sn}@MQkPQCokyZoXAw|CdB17;7~7tGm-l zIMTqW$S7Tnz(-vgf2CltvtN8T38tqWX8@ubC&?Cta3wi;KS&?aGUzG&~<}-gxWG+I^@0fZ^`AzFDL7Wz+dU zeJt-aSR2!2HdEL$>%z<|SGA7w$^NfXu#5@2O-R_pv{$URNuSIz(0RRh-b;HpE+}-d z{H0Q{t@Ol$^|_qD;nn8LBXpbo4{_%m7R9nH`T<55a?WXhAqbK)qRfzUkSG}m5|!Bm z2_j+0Nd%FMLrwxJK}1kMf`F2PfB_LDiMkO%5b*URx9+>oIrrV~e((J;G~HF*)7|x( zRco!PTI(S`t;II`e|H@Wu((*uJa9!|)v<4>me%G%;Tqn--h}TJ1#!8svQV-1G}^+M zQF!f+4757NH@O zcX!tdIj0H$eQQ@VTQ(#Jo#7gb#(PiCaIsBzOc;vb%@MxtQbh9_AqN&qn>h{ixiRfA zT#N)wl`bGZ2`v~B6!m`Ys&@v~8KzO6t1x=^3|6T_xXdsD)<`wSTf;UIXJu8U1Uo6e zn{Krvw_qf`%b(%Y$Vz*n+g!yELIpZ98z4>Q;9`_Y4_sly1PtP`SrQu4tj23{K+-{? zW}m^|J@XXu4dQ^0j40O%pK_FjZze|5gO1~G)&|DvKwo2z80bfJnaPbW-h7$7|Y+x4Q(=|kAT&I-@q$pmB+gD(jhp_P189beGxWrjDyR8 z8k{wV_nA#O2NVOrx_b#3vBs9(I19cIuwce3bZG?5O*?^=l1SnWNA1|g8}thrlm%&m zBPe4n`mrdY{kg0(gam~q=AvyqgH`6>xc<^VXqjCiSM?8^O%kir7DJ9aJ%_NLrHE@F z-J48SZeXUgr~XpKQ&6b1#RO!L)dDtC z7K#;TfmkQVkL~4hRO3vLG8C$$3s{!i!tuTxaLD$F%0CNKrE^VIRj#`H*M{VAG^;V3 zKU4$nVuZw3e7 z3^AhBKjb(~tMkI?K{!^SvrMot_RHO=AzeWC_Li&6p?tp!9wXpM$SN8Y6dMHuWybHK zpZv6!X-*&+J^4-X@S{!@L0}VaYyiI%`xD>?vEFGk z5-h|HJ@N?KS&ry>3}A99pTP-+H0;_5PSm7}fgII%N=>oq8qiP~nsm3YuHX_d10;sd z%ZAZmkz8;9m}h}Eimm^$KWH9uQTMI}!2|3?S=u;Pi~-Z3BjMR(o(`E44e3&#O7-I6 z+Yr7N=Pj7=^p@cU5z=XB6l1vigLH7z9t#O@pv{Y}wZNGc%v@aDG+_E?)(LW-01FvT zFho8tYE5Q9igiD~F9PfmQH=Op2E-?RL?NAq=$^4=(sDgMRt+F}A^pOc6a-WGr)LKz zKCc5#pf)-IhNz$AIDZaa_L?Txl9MXw6M;dxQh}<|eBEh6ey|?A?V--a0pm0-%=l>$ z#}SMUK%!;6%!8U?7ZGk+I~GjyJ;a{vHaPt!!40^hrTL zsd}PRCg9Tj%$`V^W(EN%!W4`Ix{dRIJzZ6^;3RVvGY7g$n?-Q5!un@5rv`cU-7ZIcSr zY<08#(6QU-YU?$HHsuN03)aguo!hUtjYeGK<0>I`MGt&bHZ6aE-ZSwARJ0nEIq&h^ z{_x$8qu^9Pt7S-)S!-KsKRK%Viq|Epdi{p>x>viJ2C3OS!~>OsB+ge4lz)J#G4n3$ zKP!L+l&+*3)BeeWdJiPfc^7Vz96@&=vdz4tc~USC%snZH_KY?4%|-*VeCqxd;;VVrL>hdAg71k%Bgbm zY|ee!L-~ex^*Hbm3;CdnNQjL@R{@{K@ok;K3t4N9aceZ$bDsCQTA?Zfon3k`2-jE2 zmlJAyVRo(_UF%n_?`cH$;HrlHP8s}E(8oP$W9Goe>T?E)_UCzaJ?d1d0*A*ipNr%Wsn1up znSc*-2kig)gkbC$*2QHhFZ7)3{N|kQY+5RWp`8rpUA4X_#K%ubp$74$z@*oBor;sv z6iS#8 z=E9{r1>P3xHX2eZhB_}$U#<{NcDZ{wc5Udf-^PyOhXIc{hn7C8?r;P#=hHz)(VMWa zu;N(_k4P;)_0Hg&Nxxz#j_bUIgg3QMdO2t9URuMel=-VMx%EdMK5~cWoMPYdsZ_Cc z3!WT*$=&S7#9?5t)~cDN^9r^2O=q7X1o#{ZT+t2uzSI6dEKJy2eogMwhbDNS*yNZK zze+&GPJMFGspNVNI|t+X?AqpdL+&d*s4Yjr?Hc_6mAq74|5#yArjQ(~u;=4pCq+Bv za#{HHosX{bw(oPHsWGT$KVkdlH+C;JmJM?wn9nN)I+wpyzvW{g$*)P&9qtmkbDOUM z6F*4#e;z-m&-9s&iTtLc&Q$e$I%`|7K20kwkFB1tx!7d0MaX)|M}{s#=4*8p-n&=5 zOg&<=7y=!>PM;DuT&k(F(CpmX6p@AHW|cA+>WzDPPt!|Y3D@Un(KL8nJb{wYOH$#3 z^%k+j*PAiyt~8erPSBHPjC<{1KB4wX^lQtxiekqa~AHf0c^sY6)R z{Ubssq?jxSa2GZ_%YimosRGN6fMSA zLwByT$7sjXF~~_ZGkX{DeAyHcomM%uGHt97zDI)F&)z~uq;^p&g3u zzK>afd9xy_y}(IYcuw%`gi4ZTjLo7~vJJwaqB4I+@!{!Al=43xmIJEd-$kRHmC)|M zgz0%9e*b$F%hN}VVQ+D?h}l~51c&u@PKcva+{y9*x?sJ=1i*~(&^;sKcV2(6Q5&Z7 zz0V8}fd@c~1vm*+JBG)B**h=c5jO?_sUR(MoyF5r-!=(19;G|h^ZMM@jqvasVjL+x zcP+wOYCSE_cxfoL5 z7?=RgvWPyxcCHh)?4`H{WWqfIF)mE_Vx}H!P|SPUn_`sc6G-V#`~2UiC;_<1w2H4c z)s+kp!2D!?#Yvpn7MY=IDGoZ|VV1g5d{G1#l6J@IZ*- z)}dqIXpOLqZhrG=ICLj_kRTQQ7sLqZALoS0f!1!`@a}!}ZC@Xc3|_kYCR%uKKOQ7d zJM)|{CBD(CenIA698SSkC9sa6BnoJK;MZ#S_;vb3Ouf0(?%QVcC-=ikTSHwK5i;3i z5xU|2QQGjDpquElEbWz(`cz|pWcUlN@lU5GP*MOd023_06)<82FHNYr1vDT46TtPc zVg)ZvWU+1j*A;M7h+y-c)Y)8fSZ3oJ=nT@X*Ek#1xy(u8&GzOmu&MLTU+Ofwq>AtyLmVf``Zj`b~Lq@2c&sc9)ThQ7jC!7#}nc z9z5xkXS&z|Zaq3Ixl{=sgm8Ob*qf79-?zXn-(JMhPkp=wCHCn9ujpAsn!C4T$cgF! z)ePe=AL+%ms!=;1_VhF=sEuxPjJYGuc<01(-JmBc{%Br##lm-UO~r36PQTf6Dj+5Q z;Ix-~(VHuLbP=a-uCWAGPU}Cg*-<*3}FxGr-5*ZHVqA%~*b+yVA5gI;0(_qY?PN+cr)A!x{xJ z9>`1WLQ5mOKp(fS7@61{rdX!nP_X=Q!0ak|mxa51zP@xoO8%*v_8pl&9_0pQU+x7i zmZ)|oawEBV?N#6eI7i_Z_-a0%N;Kw(&?oWDeugp)X(Y^%b=e&RRb; zzT23K3bY|ynIGQTHq#@RwG(8{wvE5o>r@f5c=6`&v?!7Q@ zpNRGoDnk#?VJ^*C`tJ`ODlRL2OFwO}tc(=?RuF6RryP^#BB>u9y>UXTV9BQH!nw$u+*v8{Fn}tluBQDMBEbz10ogT%S$Ah0hegD{R&I7QYNUkEG5O3s*4Rl4g={$qnBgb zn)MXYl=)y$0O;1}LC`1_#rK&d@IobaIXXuxr7FEpVo%a!d@$gwy97m!Mm zAetG`WLu6Q*6iljWYf+eZ!Sz7fPs@@G!v!jD5Did5Twdbsryy_8Llz-jd?)+nhSIQ zT)84Y(St~bP)T^bfUzx>8K5f4W0b{=Mtmw_QC#$K&jRlPo)7#u>aYPSE;hkG%^Eiu zbIK*akrHq$L(?Q^SQxl_^hxOWuw5xmGcd!^uCTidBK?mqP3|fjIURnd^Fm7JY z3Yzfnu=EOAOp?gs`lGy{Axl;!T4C+Mk8dxUAyKFS8mzQVRH=F6E;`VunB&9Kj z(O;#=tZ#NWsg2vXdPQkU0;xFC0nkuwzjU!E1f4D+p_u8=A7tnE@XQa;`9B8Vjf6`6 z^eiv4wdJrQVP8a+me!E4V$UUe_uj0hwOcDHPMFCoIZB^04UsyyjedGSkt8g4DVu9W z#ZgbX4~#^nBz5&}sp}=dx}rrp+O7pIf`l^xy;m=q*p$ou+>)`hYW6Rb8iUD~(*sg$ zx%5EifO3rsu*BYaTn^{E0fn|>OS&Ws_}ENTdO>Ig*Vqy~mPn)zBA0}MK^K%of*1tl zup|kMR|)C`MFE54G!TOn4yt+b*q?lY+q=jWM#zo{&H&Rr=0tOpCaGc1X~@7iHl%)e zJLpWmRzt%cgZd=Q0EkiRU{bD?gA04>`FR4LgjCONTyG4U8C{gQ@Hr0=84|anjGC@> z!|geo3yeFme{xHS;sVd>UZ-Oq0_Cctds5az8M$1DpmgM@>i2a9D0^@>JKW2kra1$L zV4KrkbA!12!)C%IlIGxC~CvW`&B0cDX zU#900VEhKa6~Dn#OOD5sPCJLpKo9(fcIKowjK{@_ReoYHS4CvP*1a4Bc(B`)Y;-R{ zraA1nPQXsade>3re?AL%?AYvQV=$Ajs3jiS8uhNro3gU%!rE^7*3K$f(0E}GtC)>} zwYwpQ$gzZ0>%lVJvfW+==H+FR!!ivsQTn~fpP=0|zyx$wCkdg&AeDwX2C)tlSP7V- zzQPeVBD6P~OaRydi9~i}ew3|k2uM?3VZ6N2$78>e2~1iM5lBf%Th2iinf0~}Ckf|S z;_)p@Lgp}-$VB0|_3pSoa5)ng>bO6W^R#PPRCy$@1K1@QF1$1-q*RTUuJt;~BRN=} z86*lp;4OqVJ}?vx)f`;HY2a3<+x?-yd_h3aCh^oq~)B_knBW6AQnd(MO9HP$&W`2Rq}5(SkLyQYdxEOL86 zfl&^7jzuBJ&`(4ga2cx&V&`@HfpHA1feg zhYfJ9wLcQON?qW$dYD{qlyjl(4fpx8&1XWGcEUX*Sc>g+5qol)@Uwx#g`*&G7%!Os zFE(|MM1U%&IpZ={Y9uB)IfIrNm-W5#e~ylISm-fpA{}4e98gm)TMP7f5`1)RE?B!w zZnk>fWgs@!as9P$>w37k=Z)J5_O{>qDyg`>jEATwdIvwA;8d6a0%^u+Pf@EdP&M8& zc&BEuc-&X6K%d92Eh``q78Cd<9Fb;{`4al9xJZ2_D7||BLZu;#UCpT^o#p;HJJ%Yk zyh`u8w=1R-sMq|uP<>&oSF@*sbEf56RgC>~RBt5Ex9>#R?UINM7`!8z>l5-_5L5}Q z@e`ltSV$VSqZc{t@%Z-aLTe-0+?+f#lLkty=cM&WxHV^@>R4%0ZTodrN<-2@p3^c9 z)O0^^vW&f~bFXMQ>G`autm{Hh;?R!bJJ)fqTn8P<>TQS6hQVX)=B^=0a??n^c0^j+#% z9+{51>ANB6`=()jm-4Ul(a1X_PlFy00rtHx41Fk!ILK<8cqG(rS83N`3V0-+b5Ao$ z)?`r8kO+u= zy!jV1PxgUC=sDvYzenUeRQndbW%dIkwjc6Az>`y)<&3=dtbax~l(sqJk<=rTEt7XQ zUT)bruW_7WG|o~ex_$ekmw%3Qy{spP8+!kd@DI@A(>ZI7h4ptVsONUVPTIAqWWPG+ zDC9?G@6LO~xqV}F%t34-JV@KL;;tF<+U*Pnvx=PH{?la8j#y)LZ*faNO>~sCns5@MgHj(6jW`3DJ`$Ty8uLyw0mu z3VeIR&9&3XbNf0TWjftH<{P#v)6_3=C1ksp)xHL8CCXkr4H@3ye{<%hjU->G9!Vlf z%v)`VX3k)FQzDU*n?%33+uWJ9?O8(z^3r77Zqbpn--WW~QH>9!ogPyz;f|UK&2pnM z0+7>I-AAn3(?+$eqpdB?K8ok+TFfg4$T~-y!BWu~M%|b9U%&ZeVm252tYo`7$c|6E zI&AzckziRKXjyXN2WTU!T%^6oA*D>g`=o}*RHc~myHPKHV#KmIDQjlVx6*wJ6{FNu zhig?A0$%OBS{i=!JfL39oA&*aN9th#W$EKHkr}?NUwbNwTuT?kn^ucHRb0882aD!T zAOS<S>PxmnN*rKfJrIMlr!pT>oLZO!9-9e* zb&dT2lyMl7M&om1vbNop%kq>V#?OM^Wg*SzcSQJ%$~F{AQ93S5!tE-6clpO7w`*5AcbXu>V_Uv24>;%}f()o@)fvGckz8c8iq z2855yfPlNF0ua;q%$d7#N^%)o&>ccZ`_5-H)G#v0scP!*d@c@M8oF=`pA?Tt259Z< z{gUqTs5fLS+HI|V()<)4oDzmB#cO2vom=6#?y)HPSl1VifgUYYfag8C3$)jTZC7fT zoKyfpg)5HZSei93~IFhN87RJw*p04|kHppvg{IT=#{>_HrWj z1}Rlx9=JYEeaYV83sWd%MhD_%Lh1Y9~jyzqMx=8`L{ZTI{2 zmRfNc(NhW?msZ$?lL@&san?auq5T>%>lv3pYFlRL^?;Nj37JrIV|cOqau)sd@E2bI z8zHsq)kHz`~V-f56W|Ar=)eX1PLY;);(asA2O|QBjUdT@yy=CwSn_;4wY(*01RSV9sJKgIIuEd2=2gA%+K8n^ZHSmyCLJuUs=1w|dD(Dsk|X{nY@f+ZpGifU7p#$Wth z+fQ*gieiUi&SGIz#|*!qC+_e+cp{|%B<>syxQP^6%X6@TS%;EBke}=cm;z0YJ&mEQ&uas@(Tc0+|^Cu!G&=jRAZu5gevK;Ybd<>NK9n>J?TMd$%u^p8}qCWS*0W z+P(iCFk;!wMkG>FyQ1pwyy_^mOUM!v{|p(swA%I*LWy6}nu9B(_$dwlL3IAaIO=CD zoXk9CPmr^~4-*bZ&8VNamXt4g63hjAAXHM<7?i+g9G=s^Z}B-q+^{YfT!;@DEUd@+ z7tRNJFyjSf98TO;hodMF3Ry5H0nC531w@JgqI(L8cewm6Rmtckh`Ogle-a8`;(#lu z#Mu%+MbBal0$`$4fuU|p>s+M6!QDG}F^TuR;BfqHhvMHu8HF!4YY-1??tWi~m#=s_ zfrU>uGHImitw%frTK_eylH*NOO zbmGK1YWJ_aDjuEkJtYYDEnvSt>M6G)xofmO&wI2R&M2kKeDJVHXEG#6@qRi7ORiXn z720QBR8@Y@zwPnE>%4V)F5?z8zD&VGfhx(VC#udoyIEUdalfMb=_^^1V^@h_6Mkf ztRxV4`?fBjm+@tr8?T?K?0#{SB%Bu>w)@4n^I$fmCvu{g5xC~7&V$LuNcp9om$=s$ zDF>Vt(((WM67ttzc`A)+nCGGy3jwowsT}JAW`8edz7@8dmMj9Mfsa{*(2L0lQE@SG zeEsykD17hM1+E@@I82ny?P1Rp@=N*J=uOEodqJW)O@Up@{9ZyR=iHt zj(uG-4~bfSfZnW!*DP{69r0APqa<*ztr1@@1b-fS829!r0c(MQpg7y!@?~e#hl+%! zL);Z|ls*%YIfU<3IUe~qf#!dJK7Tzm^_d!&<4tS+5|QJ0e0q6y3 zs7m28zVjQT7MtSJ0R*I!hCs#<%&-iiMJS&vBO85GIx^6Nhy()81gV0-*yExrtfVNq zaS}kc!EFIwH!Tqn;QtPRP~7k9M)%6WdjPe71U(7izw1X5B@_FEgzW<$wK==IV)4OD zgvOLe#PGpPlAJF<*~h_Io?JxP%xw!7$%_~+xzfv)f(vvP4a+fqD*6{f3|Fg>RN}or z+NMlDfoK`*AHtDe3rb6R84S=y>8m-fI7gX=@~`tb!~`_VN&3_=l~15 zuc5YP8JCUw9cgxk7-CzuKVrenPu@}7>5I2xq`xK#bo|#E#|V@1Ze-39%af`T;+Zsv z`v(MZxDb`MbfqCO(gYj8-c+%`+B>e7+PRhu;t@SU=QZhZg}}3@yosS&&FWOjE27h~ zHz?Gb$~%5?GAVDIG6{(s1QMX)<>|dl)f`Dew!Q+0&}&Wy56$CKX)dwZB?<)+e9ojYf2TzR=#xXZ{bNMQLD9Vk zxHEm~4irfZ^$h9(!HCJ|j{$CGr9{z|zLG9YB?O=h;3DIMR23oDTYL-Yas$9fUmAyo2vo3AM+CS`%Y^(sedQ$osZvu8 z1AE96J7mIkQ5*dP=!miA0dWe^iH)}R{z7FF!xMb6J>E>5px6uB3s^LhY0PZjsmLJjkeWPu_X@J>*<$(lO zs0Hk?o-}Wu?|&F+5`O(#`?6bG%!^+0YFz!#}Xu#mLf zi2uytLi~3mH1Vw9@&q~G!{r$H-x41nT`&zl1u;f=m@36*$f>-sYHL=4BtVo1aY?Ym z;Nz0avOGo7VYtFb3bx$TM4&MNX_AKH1S2KlY?dC2CnY|)?wUf?3Ls<($BG#YELKXl zhtHjp5yU8B^80jJKpZ#>6q8=pc*X%Mp-s6xO$ski)~5v@Xgrxxy|z=Mv9h-RxHHJC z?}5RKml~m)%S-0dY;GErJ`(D2L&F&YjHM)IFsPWXv#$9Z@px1rgsjn=Z9GcUQQ`r3J6V zR1HQ>IH<>kWs9~Jy!I-$b-y!edftM!5ECNT>AcsT*hIle(bE+|T2f2!5aCPxJR@Qe z1j|-`13=Cq^8Hi~|pS;ghZY;SM$n*O42Uo9okp4^>*Yo)2BEBpGk#}dOFAk1moHX!^H_uUc-~OjhY(MQ}+{4FP z))yMY+S@o!ddbv1TM@1!b&lYtyQ~aE=av5&MuM*0?i0VWy{>T6bkI{^g%=#3{@hDH zQaE_mcPco?oZARKo9Hjm+`dq@hS(~{e9f!IEras2XBw?$@{IyYM*Y2jH4PF1Q5hWx z%e%Vqe#UZ|A9jP%+8iF#q$9|lVjll&DK!i@bH)>;hxyigC=eL<+B1(t;Lm@*kRnF; z)-|$H=Bh7gXD5Rpw&r^?=&vUfvgF3Yf&RXx)H1K@B=Am6 z&%$-pa(-u^wMa_0QocGAdKA#&`!(41Q<+0WqgM>YVA#I-g}RB8t~<%&!XO%=P+;jS z*+&K5j31ztM}i`OIfH2>--BNRY(JpV(s#?H@m!i-+Pk=Q-fd6P;N0_dzqg)icPBRQ z#wndBQBeqakpKNP?4ecQr6&^66|LKh9g^=(Kj*JoCqDjRs{2pSlA-rQoo@-PfwYFx zbL~DQ20IZ=zV{a{^e*aFn5X22GV!K4wmV6&UJF~h+=Y1d4>>`~{@YI8x3`woD_z+f z`|$rDUg1*Szp&Tr2eA>7nfCP>Iupf#h0eJWOECwepga^cUDQ2!Kz-Hj+U zSk&Ht;{_a6Z5Sn;-e-{HYL^JUj_bJG9F3%&!euM19$`i%&$4D2%F$LoN9?-nWNjX+lcw-+@goY+(tTHH=%FKKjpEfj{D>l{RHW*ucs_&D8G;D?G${?%wA+~UEnS$J5F)jJ85e0?zHw!$z2*lk2O6o1`;R`lMf?mkZFmGgz(b9w#LdJOski zNYKLF@HGBRZ%?3giS7Lo`7x3wm1hB+eTa;1K#@*Y1S$pT4`=pC^a0GY81%0@QL{H% zy0kx|_&D&C3OL%%5^?qb1{5Agz{#JWc@Z*%!?7L*XEhaIuem|-aN;I;3=84t#4RRV zX2;Wb{9nLI!UaGprETHW{N$=a)nNg)?xF-4*9y(VXlz?IQ`3(crJGP~P z1W3kt5$aGs_@4|^Yk*FV!S5%V0`|6;dT1=0OhS61BN!r0^Dv&=^XqC80?| zfj#SgKR{CKVA|1}rQ*V~w41}0c^ ze_2|;VK^Athzu|9?PAlvDjAqNA;kw=1A`_3#bbChui{V?RqdQ`)y3F?iMd@Qgk<(y zON(=9<81i&%;`vf-~QE9sPemKp3?15xQ^=mdQwGA2yk0CCJ7SsuNq1>n%UwL|EjQL zJ9dr(3dQ;gEZT0!w?*lh=7C(f0aLO69v!*JvE@msUJ?~|%E3zZMAE<^0+o_NxgJpV zNfG&9RDaxBL3WIVm?+-g^8z6gy=;C0@Ey2I{3qehV1egP1~Y+CR8(^i~xUzD|yZ?f_1X4T~oUi zHjSKQK5?PzgzKXhylfal;yqwUh}V9$zgs^_QdfBrKD2$tGd$5hTiZ?^q2pO$`P2_S zK*n-XM~C>4fx){7qryo}h5r6YAIIkrI;x-SlvNc2ofvDX-p8(c?Uj&?*KOr@>W@@= znu8yCUe-JGN9{<~-Mf6J%Ai7Ylbijn;^B&w<8GE@OB`BOk#}Olk6{H`ByxR zD{to0MHF3qcRt2u=;Se>vC3ha;X!dld!v^XK~C@D^uPL8)!aFAQe)1xz$x^lpSm7> zKV|Z_A|o|9Vc?q~r;3=jviw%-Z=_L2wPyHTxc`w|o(9VEglto6-1?o7wI6?-f~_Gn zT7;C?SH z4F~>xCWRF48Ai|&OWotihYnxW$={q*6TSIIi1Fx9)r*H=BBv8#KOEFX4B`&&t)fdGv^%c2R9VWsUY# zfm)mS50F^|-}lM$yA($UiE2?lKnbm9jn>E;`u z*{#TBHNEYMD;X75H%4X{`kM#y(=NV$boB=)zLVn9btmz5wX)-uOtsdvi?eOIoUtn( zoIa@Spnibv{{UTgtl-_>p51dgyczi^03BKU1GIn9A`-}+MYy?}g!{7IT{evb!jS({ z0pft{ue**U+^^H?Yh<5!f8O-B-S6 zzo)o8b&wI6+aY}Zo%u%-^0_^W?>VEIyc#{hS1eC2e=fS2ZF2weHey@#A1l1uKI`&) z?R3$?3yOoS@81;<3!1)%k0JkIu159Xf6D0xMDW2@3E5C;ugUFay%SnwJ9}+ij;MKqR0Y1tMNE?# z6BMXX2_Hn-I5F`o_7oFroxe&)1nWupN`EheVLaLR-pTfx&S%f>HE(#k%RZv9A-V}# zymei}Af-pW>G|IkcgkSv8;=gBPbfayq`ZlHM5EtT`RqpQ*A>$r zaGYMJLj2kx{q|-oe%IT@etD@=1ocS-*ERiq$yMyQVKu&EvYTj>w^6B8x0m6#kGo?x ze2I2PD>@{JG_dEluQfvY04lvq`6pu`AP4xbw|X9<#{+I>)wbTO-|yzXXz(rAZvxgu zW7x>T>qb7Udj^L^gVRh*{?v)(Z-_IA010fquImE%(1^@PE5B z*0eOuOG<@w*jI6X&UJ!SiKGfZf@6Mt7Bd?ZDE0gutG4(yz)|{pv&g@z_owfGKGDV- zmB0Qjr`YlO5n5WBoj#-Ww+7>~HPl7`dK5P5Aoi*29B#CHoqyF(;PD41nU>i*ye@R6 zV3?adD6a_UkLgsq+*{FWd%ht1tX2m`r|G z+J6%UqCYkHB_XNbc_j@})pu-sZ-IAgT-g6S6WLyIW3RK~uN%UNL4xxB>$^h(w~H_B zZNZG}{Z`ofi-P`pOaDzf|E-V~o5KK0pQMw+zW7TAH6H#Ko_Db=ZmDgJwIIoGs`zj3 zJoyO$JA!%n?H3XJOS zgDAqs5Y%Dulk`ub6;{)q$PTUTo<76yCS&R^m@J%USvmkUUli-b_*KwU-@=MTyng=M z&uE*FOxJZ%Ta^TKlpb{CIx{Sh7xWNhpz%mY1z66vdfJb-$a~lKNO_+%`RYh2E*qZN zw#rHO;Dvi1zKd~jtWT4Wq{rwV2Ju9%&IY=xOug8Tx?2sVz?i=gSozmOKlxMQl3zwq>p;@_c&rP;sw#VWzbzcB-?5+Iek!#X3TXGV;sE ztc|cys+!BBC=w8ZrfZ(a>6aI?EW*-aDr{fsHp~a+b4=(}uMIADJh(JC6fV)b!d+fe zQ=k^hV)wvrhhFUcnYD+R6)b*1U^zKsXY%x|rt|uKe&P1(m1wH<%_|7EXH|FqK)u|G zELjUI;+Phj4Wb)ru7UUthC(k%x;tfspAw~+A9?=vuDMrR{6%|SH=-fy!MaIK1GE`a ztH+C3dc=*G_j%x*xIUhoh1CqNO-Xuwd3KpA2k1kg$6I#iOD34s0^|NTx;@P_-=FL6 zs+Sq!Z~NhOy5_I8wS3GVSZD;eHdDc+f**7Aar*c?JleY$% zhx=KQSuSzu_WEoy@hCtVK#SJvr?mqGj!X|KpR35XxqILI#NB<19Vr}7y^}tI%W$S zvMxS-+T&z_N-B$2UNjmU*<414dPN%e!CKL^g7ZpBZM-eL-%YQ~y8i${UN_H_iUd9r zz3x4=uBAN|ND)(3li{{X8RS?A%=d_3_?R?bJ3U|2)LtOs`_^-K{$*K1kZ8tRC)TqJcvGjsyl*|R_TfL{EuXCJ^+rC%s6t`i4xXC~0K&FP%U3tBH z^P9<=t3c4rM_!V1FV-W1M_quoKdY${LO*! z_6Nz1__)*$#`XfXuABQwPjZ_2z)AR>y>hcCd>@GeaSxjpOwO;RRiydY&Lok7=;XOl zp@$V}p9df4JUNWs{&?PKIzn{XpcumBeY)$!jGZ|0gIGQ4;J9CaWr3Q$?|0LH*GH{ISA{HHGwuQu89=D8!Q?VV?*boBjB zBjBvvaE<^1`SaXA+x(5kSJIJ@zftgR0LT8~yw! z3Y0zF>4w)W+XEStBQ|!N1S5OA!?hf*&hOh@IHaHUQuIV0zwscVJc?>Zv*#!jNKz#l z=2KSxnY(k_z?N-drWMY}o<7kj3?Ou-UuwI5l3HAV-@V;K7NS-POrIxL&xwW9_0z>6`i7p8# zL5C*q`c^;5#^#6njl)GCljnC3tMfay1;<@|YbQwb;hC$p6`xhB7%ixQY;WjLio5>MxaTas*81R6NjfE|F!O6>Mv!Kq-e6n<)0WdPRi%x*clsrR9iY||YgveS&LkkU`Lh9hRi2qVzn&(LeMsk^I zoezX?tj8`wh?TymyQAUs14;PAp0e#=>L5FuY3k6%>DfJ-Onwp#pS5HzT5$G}?Gp}$ z8m>p5EQilp6iq;VYK=E<%jYds2fy(v6Lu7VeY{5EaORS3bVF^z1(tc4prz=AxYkr) z32auoCUuaRsY^Lrb|4Y%uSuc`gptEVpgD{d!=LqD1Nj3A2gm!ZQHSmBSD zRSJWiq<%&)lhQ=JO(tQ4av`;I3rYB8yFqL!BZpl7h0sNDQoR5%4;Rw@uRzDp`l3?B&XMu9yQ2^UQwwbY_BkV4BV zfdte&R%X;dN|a8fWxECHWx+*~yo0>o z+r`-BiJNn^%qEIybucmuO7xt*f5njKIURWxnr$(5aE!{|@?+Z*&kJ&pU3)hC5>EGoQ{XPjHA6la?hD zl0#wYo1MXc(2G>}2NQ9HqK#MSybppA1bkj_g5 zEtP*qKYL01EgeE{ljHL;%rIU1*oL1JufIyCKC4@j(Xos1;OG}s1Pu1n9I0d24BT6B- z<2pEe8Ui0~z(-J8To(YEK65~EkT6;HuP}m4h)R_PGe938UgpCd06UPIA3&?*diN!#WxD+FX z5e62*AV6f~Gl~;~(pB}r{{@J+h3M8)?vURTK*>I!6AFU=)#}2*&pDp{X>roObv&z) zi2)-tYA|xeQsW+_AG^w*a2yq;dN27&+pDXkCZOzZd!b4I52_hIr2|p?HP9$yH$g}c zrdVO1z%7mK`(G3rkUCt0;{fq$>FSd1TKze}O1fFKOX>@1n=rx3bfLMUOX@AEvRW30 z3-AmZXdmfr;V@^-)fiD++v~l`rB@Na2@C96S4?kqEXPnuv5VaE(&{n-Ft_R?a{Vtx zF^qj0e}Xy~K<`am33Le$j^sL|?9*A%X2mXQt={I}-KJKtY`^GS(cHQ&+0frvl?Xew zpH{Z~s!KuTJ3`)O%Uk&t@8|g?l2dnfV|f#`zGsbb^WK@6_sX{nj@eAE@L;%XO-B8? z9O*>1JfJncCSK*qX^?VOJI98Nij|%C^foU&>!w0uRwp~0!nfw^SJAIKr=1n+JpHfj zo^aTTbmMdlMw)nwS5CaO(Q3IzS<+cacSQ?0BQondqv-fNw}DsnY`$63hph zL9QhWt~!nUa-P=^eMCRSt6F7xd8_k*JAKn;P<28=^2HNvp3duc5DCEHBE*-Wmvk=p zO7}F`g2+aX1AF!3q})IeuqPJy->n!_IWlv)$>l{{Z!=78?4x(raDfYeD~Ty@{iBkpK>BhQ7ioX zK%_}XBFgCI(DAUxospBHnXkXMj5{6Fs~wa?K0WnMhsmb!Yj1f+)#hoBR*uZVu19W6 zI=zeBiTDBP9Em;({{dR-jI8~4rDjJPnp!wdeB=$ag-h=r8Xd~32_98By}P>ea<4Hf z(#T|xY)=ob7qPycd-UPu&4pJFiT~I9|8(BDAb-p>9z?A(Q8!sQc6H`OeqFtA|esaSMF*-%R%ULm(u;kJ*h#_SY z!CBFDr*sS=tUC4k?ifCsy1(3GKM+b0DdvRB+!%k7XEH(muwXC2LXCSQ8<|kTxVyma&VLNg||0+e4|}nK?7k^M1bX_viEbZvTA$ zIJ4Z>IcLtC`*p7SbzS#$Ul-L`7RvbO1eVfBBKtRK2t(dP(6pPX5H&b>{#Y_u9G*xJ z)BgaRi+MH<5^7{$38MuQ6}+FLgvcv|H4yOiMel7;dE)+*h+raCI)<&aBUyaU%$2#{ z6$4RmW9x49N!T2d0o6qblQ#%N;ek_H;4m>wG`)8eg=h@Gkv?LNE zDUtR~U8#(xrifBjL!bi8fgDn#w^0PDjsr$+nRp828?T5E3?eng=7PX+WAZdST-Je& zZtBg(*!y_EhG4@Hh7iRSa`3J;2^*v^umr`^@LU0BI6e}QvQ(j+i*N2aPg^dQL7rH~ zJ`^R*#WKIE07e5s4e1o%qSlzXr-z6TyGV2uT0e@%c0Sz(%@u1-v2pHK}< z@L5Z7bK|IK$0I%#s2u|(Bt}%c9rA`Fb<64m1W01KYS6}A8a1Le&ssEmk8A+y^j=?r z^U|tMs12QArF$41*{I#juu?Qi{SAF+iUie4dYlTU0!*UasUBG`LA7ier?DwTmOsTm zlBkrl%2J3dL(a>EX<$0 z-V-oh_#Yx~i!zwPai^Eiyaeze-|Hm~)@8#HY)U-I5Nv3oXCL;tx0EuU~Q2veX zKNT|T384Mi#M04A8r_1W_9^)G391+ci+a_$lLKVGKfe-rxbV&A;#zG9>XL99eFjSR zuqbD-s1M70rgOrM05x8!(cH{}JTDiw=Rl;(^5~2blscapL&z&1lMopjgHcOOOZ!H0 z9|tTimTDQV75Rs_<$YFpeNUoF?Nfvj|J&YXq$C@t()HxD!+RTx+H5SgKYBuFzO|Bl~XWdEX?O(HyN>H$S97f)T< zfs17B4uGb%B9f!1RK2Ej=v(z_u6i!Ck;blSE?+`3w@Q))a#l+@ztM>K@e#L7Qejm1 zxpW3|6*=NH?|;(VrbmQ>v3M1abhf3w$|Tnk&6hpSjz`iuWfJNYm^Es6nkO0Jx^RZ< zzvl_eYOCb|kD%d(weI0Po27ee!;gw5!)qc;oQH6yY##QgZC#%@J9|sjV(`uG&^U{} z5h5mR)a>(&qd4?zI!eAk?|=x@t50bKlUDmUu2%GxGAKcz&zp?eVK3$c_>J>X)4rb~sUjuu<6l z(~BB)LpQFD9Jo1l_P*tJ$rv6GT@cPon8C9_q}KR^Quz#eod)sJUfu@~XB(Z3C3+Sa(>f8|EM zc(~vDVmsnjT+JEc0qufG2(HTR$rI_!)s~^%mb?b{i^6Z^y;^nW$-*Jp)QNf+uO$gD zF>>m~CdIQ2T6xU3v%8y~j6_+FPOhlc49b<*FWlAtAv~NmI5FS!fA#y<`KE}O`Yrj2 zX7{3Q)E=3>{xCJFlbw7#&#w=3R07GzUrxQyT*yCmF7ER2Thr024MbKVSe6+k;HSOy zpxn&Qi^tFYf;_7PYV5rB1vY`7Zg2ltg4$BRNk;PX$WO+pZ(HXQ`wM?T_MZom%lv}e zc)$2Abj7C^zaZOZ7VZD7eEQ5cXO5dx;79e((R&-_f~Ky_YkdLc;MSd@zwbPLVZraD z<<>`*@odshx%;#87sUJR&*xu0Eb;58)Bjq8=ZiV_i2t-GRN#R_lwD)Y?0&#~(tyDB z`7Q34Yi@6mij?~91ZF=mrIHo-p`dpXi8U4cT6#qR0Yv87?M9p6i3q`*0a=0&l!n&v zapXf|&2>kjChUDCbS6B^p#vZDoPNSd2r?5!AS~K*V z9?m?vl+{R~98yKCMIl`oFEOHpmcr?pvZ-ic)0a@lxGkESmZTRRNv;nT;Cd`mW%)!^ z70<&hg?XA^wy5{Q$7@8ktSbffQZOA4G3SUO3R8+=1#F#%!IPCNb5s1V>>=OStR*s` z$IO)gXvJJ%%eC=ZsSLzl*Hf@}P4Hr4k5aJpsaRWHrC^?xNEq1wi=&P?YskX5we3Nv zGJW*5*4M+>2dy>~-h&qCeOPA$wJX?MR5Sk^-`N^Hb4p>GiZi1J1IuSMit;rkVR2Dt z#npanBg>e86Ny3ZM!O^M-F}tWSPBCrty>17!q^lAEVQI8wQ>q>in5z4%H}t$sCXVF zN+3cAn2SVP2;ijDQv&#Hw_p4k1%?8u|C2-7K~@ik?KGnS{hdYxaKNLqho``&HP0a} zfZL?xjPM2=Q!JioNmtY@JKa_TAc#s`r_iJO$~Mt?|MmmZ->!N(n|d$#GLiU1RE^DF zeFc<6%LG-Eb|sdOjT1X)6I$@&IEB)SN)I*4bO3R%))LxSBWf=ACK<~MV^~S)7Et1K zRHfeHO{K+>!m%98u+1SDwk_AA+El!VFAeuyVpKBo#^}>(1UwDLsh=sBjn|ooLoqVl zAdk<>DjGYmWO%%RSTr(KPRZ3kkv(*>z{HQOz~UqY;?T)UZ=y=J7J9zW#x#XP2DA$7 z7Ha~~&6LEVB-{AEVFtl6epVW&iVVj`Z5yuqVf*o6?V&M&aXsjkb>$qd=9oYN48c4E zJiNzaDXI^$WC_6<5-CvNjSVM`7!Saw#u>+PXtzj;BKR7|3;4$biUcf__Y-smW8)d( zdC7h3i$(FAerNnh(w!TJ`Z#~3h4Uubud=fms$exr`^eh zR}>fkEG$`~C~*DxG8)&!!$OiHK|+L>^YOCTG9++S4)k9|P>D2A+qiVl5@8==&x1gw zvUE;47b-Lb!31EXGYBAkFq4Nb%+n?*T!YSz6bCgAh3)jl;B&;zRGSf4y21%9cr(9Q zPgx2CN)}szurvt|7c#!kUbFk@FlbvY7Au?33}-`zgOUOOG;Em2+08GUy!L*d1D2$v zDJhV&RGG5YQpTiRR7=O;H7|q(odotI2nSWA)GpR62nPe()VUb>3ak%%bJx*M@%2%k*ll-cM=>2=Rz!7sNL_?eJn9y!D=sUU4v&$1W#icbJAor5P0s@$5Ys-g*d}& zxO!|eZ&#rlF>98^#m408Y%U8C#VXb`&QcetTh)@8-{IaDAgDJnE~2`N|OE zPX#;{H<$ld9MbKwy?OD_3a8;mp^G5{-t;+kV0sP+M$9uosPAgcU z56%vkZ@+AQb&3`A4U(^zcl%f6&Al%}A0Ow;(fZz)H`Cg4xN70ZoXamrcmu$rvMvK5 z(OUCy{{snjAs!?}QS+1E3BMpufjMQZoo5!0`v0i<*?sTZO~%&lQ;Qp`zF7PlY1!6i z#CRHe?Sj_a!|#DVhT89yTpqvIKeLRE0pZGbkMH#${2XM!@;E8}m8_3U_rtBD=y1;$*R6b%g$ElN703|CW2rwV%8 zmI`ag(`%?mn~qY;8a#CakQjDp7y4kMuCx)u1P!=SGL@Vm(qldj+A!!v1BbK23}}N5 zca5TT07UPZfle*k;%z(oa6TVK{keaF@gz@31T{IutoIRBhvupEGEu*VYK_LV_>H!A;suUgM{Vp9d9v1QJclvyv9gdjA6&^Fv4M{$&pIn>+{VAw;iLk| zcx>H%Q2qvGsQ|m_J1VblW-!la0q>g3P}?`ya3C>o@hBw{&I5g2^iHqFJo=GQOhM14ELXB)+C1N8a}!{ii!%I8KwvX zU~8V?bZWu+E^S<3a%Q@9c<5;7z+7oN5nqH)WS%(BoxKQ|NhFy-ufUg@ipBpq*lHnxFjl|<;lIAdla zRkV45yf1w9w%U1Ub5F4(f@ATunMhnqh#mVI-WL`qmgL1rM5iJ_4MZ?oLO3DOg4xc( z->svGf{I-Ha$&hkL-++Ep6Q10g^o5`tceb={h$;sT$eb4t1MsTi0(AM3L_OO-aXwUc!|AYY^( z%4V8q1Di6g!2Wgd_%-yCMm5EFP31rE$Zl3QR1HOMvZ3Lqvpw+4M^zfC!Nmo7D!@@} zxT;H>3xpwpbtrSahcV8I#$F&g9ZQ|})uTjO02HSY6=6Q-1$MO*Pyh5fG$!b*DBV>K zW~5Wc(PfB=(ie$K+Ojawl2n0>%+OUE>m3|HY3CRgCR)1?v8|rdztwJZqA5yomtooO zT}T0NZD59otwoK^XwZu)VQlr9WkpJc$P~5XmFW;nU~Z1F%Ry+#RC}iKojnklNfmN* zT{#4Pe7p+{8o?sSf9OkHel7MqbQAywt_|UX6!S=uUL)gHtY+WZZyQj zji+R)DU*Xyyvsl;;V#(+#Z|fpgfrmb1MF0sNp=O z<=gcvMhoauk?Kv2X~S+#9E5VUts_dt`f+q(iVCih$VpNskzD+Z>T)D?3s5%;UZQ-X zsDusD8u|yIc&~+f_AL{!BAWZGplv$WG-m76)jX{|Lb(W5PC9lBnZ&;J&PK2gmLEd$ ze<}`^r({o83>!Y4EZws1s0D34)1yN4j>ajc4-vt8b+6psJGgHY-EgJJe%62FB{s%0 znHe3l1s#K;6#q%9 ziQSChql$r}^uxQKT#?*`NT}z-TEWi?u#UU^-TOgpK;XfVHJi`XRt6*!M7NAx*DHq+ zJetr7E9B6}JXsk^B0G<{n?%1_TUm1LMBYB@F;bM;=IgAJchdu0oF3M0@&S&|x=`-s z)jxPPrLw5rvZ;+Q%SuMFUw+$_kn>XCFQj8%9@=ZS-l%pk1JcUB;yCL%AACm+*`Dp* zS*%)}^>nYzEm6}Q5w0W|AnQhd>7&f&LK`LOlM;{{2vh7csoG!dgnjJ_o%z@klV&*?E+J)Kzr|_Vp(rq&o7P?iHSrdAFnIUh+3=`-9?&8nlN_n1#g1 zFw#Ty{1EG)_Jxff?~Y4lM?$(~%d7*4kY_5}mI=T`Bnd%I5afJ&uGa4FXLuI9&V99M ze$4*G!Zjocp&{%o$V=;fU&`7wq-d6as~iOmu&zOswL_{WS5ZQPoCwJIrd*9v=07(+ zy~MgyLaIh}V^nCoI(tyBxc6I-$MGd{#&EM+MyrM%x=2jgx`nX+g0x=Eh9Q`ZyKsE( za9CrmyFtaWv&R3SEtgBObQrTAEK`u8ImGb4lq}%OFVu^xMBP|2LZkD~clnQISO+1Z0>;DF zu*dPyW&JS5(v;f(P;I_aXH z6bpFE`q_T3=qLuV(JGxgMoTP%@RBuT9RL&Rt8)MSFUFpJRR#rMkE)-*;EveI z8bV=>8B$RN%PvM*4;-=m*jCRUcMZrB-k2+u;t$Siv!6_j@co5Vq&%-D3GqD+M|{u5 z1yN1OOBT^G&=p+g0MQ;Co=I%rgGwQrW8oxetl(ju?ZPiREK23{TbZ`${GBB(yG%Hznh9vb>@SnE`2N_i8{N^GsKt6#)ssxU3Ah&V zJM#<1I#f;XZiNL>yD-6!=UaKG#QkfIGOM?JPAvVLSQwrsx)6E>^3Bg@5!<|? zOJPB7L(irICZvli=+zZRNV6d}aT_C7{~YQDYM*r8qwVKgB^@-RYVUFM|if~+H81C*SEUkf@|x2AWOl!Yd4?#-1+%r*Fr$$ zBD+On}12mT^j5*AKgn97R^oVt6a-0aNd>Yd~h_`#-|T{k~|ZCsEX z8NF-&+0j|yZ1=v;^pv6-%`pnrr=IVVZcn{;$=GYN3}+4(k?aX>!}4o^63BXUi$Wy$+Sc1bojfQoml8+*xlHHxl0GD_VkwQ*FSuN$WxLFo9x$g`wH6<^OvP1 zVNtPo;aFk#$#bJ?oJNEh4~I&jD~s%WOD0}j1j_Wi+^W`Kc*$bta4AV#qhK~~2!p%g z8E5%@MfFJuQS^y>S+~L;_sX2W+>&|zVGU%CiEpsYXhyk)3P0K5Q(V(q(YwjK-IfL$ zv1yp~d2?VD&?A>#OFU-uHz*|Ari=(VKj;ur9>2+??{|Iimm5R!^+0sAi1@Ni(uw9L zu;cUyz+TBcG0%X6%PV$*0it&vgX?fsCv7kSE`Melz5qfBy?o^5rMM zQ2(9W;(G;Wj-6-uqMLIIdCLMwl~%u2;&{-1yHx*LqOybs|5K^o#>(Z~=*PT{?LK`q ztTN#%jWfSJIQFDZw-fZOdb%I>Ry<7s9jg@&Zj|_(Kb;*ZrkyE1c%Ovy(6I;K0AHwz zhnhRWrqymYKy9Uy+U-V(2^V!@v8Ekfg7}2&N6>g+n(&bfNn~U2ctL%QziuX&<=B&S z4ZqPgB&O=^f&L$7aV&w*XvDKEZEOeWE{SCCLj?l2f@Cf~;ZF?E5$a2NY2zH0)9l`N zwF_-eiX}7z=VIyivCnW_D<53mo2#N>*zpqL;+u0TPKj;8v-ghQba~A2%c=E+q&Qc> zRxGC&#~}s5j#jkOsBpb>62YE6G1Vv1i6G=;H>09-wWFcZskYiYGJ%14+Oqu)rtfVm z5aMUM0G0_$q8|plo#F`F&n;wv*d1+Iw3Evw!0*YQOrM=fX%X4fC(;B82f&5Ok4g0h z^gnWI^bKhw#`KA0<4JV_6VJ8FD(H($(nVf%eN9OlTQ?a=ffCjA6pdTD0pH!&E=}_X z%~J^-YE(A4j2%tdeq%O9Jk*txox6>HCg>UR$=IP3`XYmLQB~bgQ@_JjSx!>MTUC@Y z6R5xawyGmITfJ@{yOF!%rh)AqDy+B_?on%IWBaxxm~bHA9pDmD`|iUU#O+$%by%#_ z-gHCc0Te`oAj@pYWVe`#gH7CyZb9WF<;RQYN)z@4=rg=^l{vZ95?F06j&pviUG5-N zMon!#X0m-fCTIn3odSMRjp?(8=_R8Gc<3aK-Ot&q&R;otkP?_X$sah{I`!4qz2|Bd zTNcUoKZ1>?$ChHFnqBI*Ie<8WPsoUX6n!PPq5|eL;C0LlXK1pby z8IC4r1n~{Xi8Y|)O6+@r>F&Ym*h>2Blav9l@71O9p2UyEs)oDcXjSyXP#7*tE>I&+ z*NE{O4cr9AnspLgIT!soH7YV#JxXl=Oo|TgZHD06?rS9FXKx2y z@>JKj*0#}BH%8lc(1$P4_6Ck%MwdoTwUVi$f>EKb09XOto?SDe8x19uc)ycPHI+t2 zc(L~8#}=@0;1XcS^wtJuO}xF=vpX6&sx@=(tdmCFM%(NZe-bMmzP?1N%bB6Wt>pIT94+{p${fJrU;Gnx1Bd;quDS3fl(m9 zwm!ip-VA)YS#f!o82WMPIjMPE7|5uhp+LbTHevxxtl>@o~s; zekE&}&gcTqcqDU@@WZ0QY2SLxao8OGLkR7sBIEe9$PnND0?D({Va>Y>%Jc7SD-ea9 z+i=uywO8P?-cgCic4x2O@!qB62gPdjlD4XZYs3|+1;_;Ue%$-^#7+XV=u~rKXvPEH z@ppc%0}BThB~A|Y20o$dENr?eU)z#CNrXzJ47AP5yV`rqkS=Ui*z+jGKjCbgjKz!S z)wwsXgzSEEY0kp8Xz=s}GvWH$_bUm{H9QhsErxd&2F**>T(2CSJzuyTv!irP-1LXe z9Z#d?>NaTlecx~t+P`IIz%#L(cMP)**k3K(`wN1|U3@%KH~zfIr7)*<{p`voyQ|2h zme;f0$VAjtLG9cKJ* zc?wn6pyb@N>JYRai=Ecz?>W{TPaKinr`dcdg1&?fA;Kzq+aTl@zrP9|LV%|w2>K-Annm-!I<{pX z%-eAbfB1RvHF>?5@2)Fee8v7H3Eag_v@dX}S9_4>{^J)%VZV0tQAPf~`N6AGvgn%2 z|If4dPcewM9X+nRky=W>uk;MpDk|fq6PULNjr`)79t6b6LBM9T zi(7hNt_KQ!^t#o?E`%E-$jM4&9tfcWhV9O$2H1EVn-9%2{fDC4d$DQ?f*C-Gj2QbDfb}bgzW$X;QY}JuG69BT-r7*}(F>PtEH*e}oM#e{+HeOV&}^

w9PC@F_4m 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%0DUJ;p`N3UnIt3` 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

QR<tGHKIxN7R~acTzPh?a&P7?KPVelMPu+fqx~4 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

GZy$FAY1Hdp|Kh@^`;8XL*QT@R&jxM+G~r&y;y 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 R+EL`uk;LK0w0^qhe*vY|={x`c literal 0 HcmV?d00001 diff --git a/artifacts/chrome-nodes2-cube-mask-controls-final.png b/artifacts/chrome-nodes2-cube-mask-controls-final.png new file mode 100644 index 0000000000000000000000000000000000000000..f5ce37edc1a0aecf594953d37582d13b14669de5 GIT binary patch literal 244011 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Ⓢ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

_u$o>YH!cZRjlg_^vv(VF@}>6%P`et# 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 Date: Tue, 4 Aug 2026 21:42:19 -0400 Subject: [PATCH 34/40] chore(standards): require component-owned frontend elements --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 357565b..e2807a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,6 +260,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. From 02784b1ebeb230877a3fc88a8b042a40b15a5869 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Wed, 5 Aug 2026 22:19:50 -0400 Subject: [PATCH 35/40] feat(conditioning): support labeled prompt separators --- README.md | 8 +- simple_syrup/domain/conditioning_batch.py | 10 --- simple_syrup/domain/prompt_batch_parser.py | 32 +++++++ simple_syrup/domain/prompt_control_prompt.py | 2 +- simple_syrup/nodes/encode_prompt_batch.py | 24 ++++-- ..._and_encode_prompts_with_prompt_control.py | 15 ++-- ...encode_prompt_batch_with_prompt_control.py | 27 +++--- ..._and_encode_prompts_with_prompt_control.py | 18 ++-- .../prompt_control_schedule_encode_graph.py | 4 +- .../services/prompt_batch_encoding_service.py | 3 +- tests/test_conditioning_batch.py | 28 ------ tests/test_encode_prompt_batch_node.py | 22 +++++ ...e_prompt_batch_with_prompt_control_node.py | 2 + tests/test_prompt_batch_parser.py | 86 +++++++++++++++++++ tests/test_prompt_control_batch_graph.py | 19 ++++ ...st_prompt_control_schedule_encode_graph.py | 24 ++++++ ...prompt_control_segment_planning_service.py | 24 ++++++ ...encode_prompts_with_prompt_control_node.py | 4 + 18 files changed, 273 insertions(+), 79 deletions(-) create mode 100644 simple_syrup/domain/prompt_batch_parser.py create mode 100644 tests/test_prompt_batch_parser.py diff --git a/README.md b/README.md index c9a3748..3fd7650 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,15 @@ The regional node is different from the per-crop detailers. It samples one full- ### Per-Segment Prompt Batches -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]`. +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]`. Separators can carry an organizational name, such as `[SEP|Sky]`; the name is discarded during parsing, and prompt-to-SEG matching remains positional. 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. -- **Encode Prompt Batch** splits prompt text with `[SEP]` and encodes ordered positive and negative `CONDITIONING_BATCH` values. +- **Encode Prompt Batch** splits prompt text with `[SEP]` or `[SEP|name]` 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. - **Batch Region Conditioning** combines normal `CONDITIONING` values and existing `CONDITIONING_BATCH` values into one ordered regional batch. -- **Encode Prompt Batch w/ Prompt Control** is exported when Prompt Control is installed. It keeps the `[SEP]` batching workflow while letting Prompt Control handle prompt scheduling and encoding. -- **Schedule & Encode Prompts** is also exported when Prompt Control is installed. It schedules Prompt Control LoRA tags, encodes positive and negative prompts, and returns normal conditioning or SimpleSyrup batches depending on whether `[SEP]` is used. +- **Encode Prompt Batch w/ Prompt Control** is exported when Prompt Control is installed. It keeps the `[SEP]` and `[SEP|name]` batching workflow while letting Prompt Control handle prompt scheduling and encoding. +- **Schedule & Encode Prompts** is also exported when Prompt Control is installed. It schedules Prompt Control LoRA tags, encodes positive and negative prompts, and returns normal conditioning or SimpleSyrup batches depending on whether `[SEP]` or `[SEP|name]` is used. ### Tile, Tag, and Guide diff --git a/simple_syrup/domain/conditioning_batch.py b/simple_syrup/domain/conditioning_batch.py index 1b69ba1..b7961b8 100644 --- a/simple_syrup/domain/conditioning_batch.py +++ b/simple_syrup/domain/conditioning_batch.py @@ -6,7 +6,6 @@ from __future__ import annotations -import re from dataclasses import dataclass from typing import Any, TypeAlias @@ -57,15 +56,6 @@ def batch_conditioning( return ConditioningBatch(tuple(entries)) -def split_prompt_batch(text: str, separator: str = "[SEP]") -> tuple[str, ...]: - """Split prompt text into ordered chunks using a configurable separator.""" - - if separator == "": - raise ValueError("separator must not be empty.") - pattern = rf"\s*{re.escape(separator)}\s*" - return tuple(re.split(pattern, text)) - - def select_conditioning( conditioning: Conditioning | ConditioningBatch, index: int, diff --git a/simple_syrup/domain/prompt_batch_parser.py b/simple_syrup/domain/prompt_batch_parser.py new file mode 100644 index 0000000..0169560 --- /dev/null +++ b/simple_syrup/domain/prompt_batch_parser.py @@ -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) diff --git a/simple_syrup/domain/prompt_control_prompt.py b/simple_syrup/domain/prompt_control_prompt.py index eaf3311..842fb3f 100644 --- a/simple_syrup/domain/prompt_control_prompt.py +++ b/simple_syrup/domain/prompt_control_prompt.py @@ -9,7 +9,7 @@ from __future__ import annotations import re from dataclasses import dataclass -from .conditioning_batch import split_prompt_batch +from .prompt_batch_parser import split_prompt_batch PROMPT_TEXT_PATTERN = r"(?:^|>)([^<]+)(?=<|$)" LORA_TAG_PATTERN = r"<[^>]*>" diff --git a/simple_syrup/nodes/encode_prompt_batch.py b/simple_syrup/nodes/encode_prompt_batch.py index 08654e7..5e5de55 100644 --- a/simple_syrup/nodes/encode_prompt_batch.py +++ b/simple_syrup/nodes/encode_prompt_batch.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import Any, ClassVar +from ..domain.prompt_batch_parser import DEFAULT_PROMPT_BATCH_SEPARATOR from ..runtime.conditioning_encoding import ComfyConditioningEncoder from ..services.prompt_batch_encoding_service import PromptBatchEncodingService @@ -24,8 +25,9 @@ class EncodePromptBatch: FUNCTION = "encode" CATEGORY = "SimpleSyrup/Conditioning" DESCRIPTION = ( - "Encodes [SEP]-separated prompts into matched conditioning batches, " - "reusing each side's global prompt when a regional entry is missing." + "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"] @@ -55,8 +57,9 @@ class EncodePromptBatch: "default": "", "multiline": True, "tooltip": ( - "Ordered positive prompt entries separated by [SEP]; " - "the global entry fills missing positive regions." + "Ordered positive prompt entries separated by [SEP] " + "or [SEP|name]; the global entry fills missing " + "positive regions." ), }, ), @@ -66,16 +69,21 @@ class EncodePromptBatch: "default": "", "multiline": True, "tooltip": ( - "Ordered negative prompt entries separated by [SEP]; " - "the global entry fills missing negative regions." + "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." + ), }, ), } diff --git a/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py b/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py index 1d9266e..3166d73 100644 --- a/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py +++ b/simple_syrup/nodes/schedule_and_encode_prompts_with_prompt_control.py @@ -30,8 +30,9 @@ class ScheduleAndEncodePromptsWithPromptControl: FUNCTION = "execute" CATEGORY = "SimpleSyrup/Conditioning" DESCRIPTION = ( - "Schedules Prompt-Control LoRAs and encodes prompts. [SEP] creates " - "matched conditioning batches using global text for missing regions." + "Schedules Prompt-Control LoRAs and encodes prompts. [SEP] and " + "[SEP|name] create matched conditioning batches using global text for " + "missing regions." ) SEARCH_ALIASES = ["prompt control", "schedule prompts", "encode prompts"] @@ -67,8 +68,9 @@ class ScheduleAndEncodePromptsWithPromptControl: "default": "", "multiline": False, "tooltip": ( - "Positive Prompt-Control text; [SEP] creates ordered " - "entries, and global text fills missing positive regions." + "Positive Prompt-Control text; [SEP] or [SEP|name] " + "creates ordered entries, and global text fills missing " + "positive regions." ), }, ), @@ -78,8 +80,9 @@ class ScheduleAndEncodePromptsWithPromptControl: "default": "", "multiline": False, "tooltip": ( - "Negative Prompt-Control text; [SEP] creates ordered " - "entries, and global text fills missing negative regions." + "Negative Prompt-Control text; [SEP] or [SEP|name] " + "creates ordered entries, and global text fills missing " + "negative regions." ), }, ), diff --git a/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py b/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py index 6b4eb85..bc64520 100644 --- a/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py +++ b/simple_syrup/nodes_v3/encode_prompt_batch_with_prompt_control.py @@ -9,6 +9,7 @@ from __future__ import annotations from importlib import import_module from typing import TYPE_CHECKING, Any, ClassVar +from ..domain.prompt_batch_parser import DEFAULT_PROMPT_BATCH_SEPARATOR from ..runtime.prompt_control_batch_graph import PromptControlBatchGraphBuilder if TYPE_CHECKING: @@ -47,8 +48,9 @@ class EncodePromptBatchWithPromptControl(_ComfyNodeBase): enable_expand=True, category="SimpleSyrup/Conditioning", description=( - "Encodes [SEP]-separated prompts into matched Prompt Control " - "batches, reusing each side's global text for missing regions." + "Encodes prompts separated by [SEP] or [SEP|name] into matched " + "Prompt Control batches, reusing each side's global text for " + "missing regions." ), inputs=[ _comfy_io.Clip.Input( @@ -64,9 +66,9 @@ class EncodePromptBatchWithPromptControl(_ComfyNodeBase): multiline=True, default="", tooltip=( - "Positive Prompt Control prompts in positional order; each " - "segment keeps its aligned LoRA hooks, and global text " - "fills missing positive regions." + "Positive Prompt Control prompts separated by [SEP] or " + "[SEP|name] in positional order; each segment keeps its " + "aligned LoRA hooks, and global text fills missing regions." ), ), _comfy_io.String.Input( @@ -74,15 +76,20 @@ class EncodePromptBatchWithPromptControl(_ComfyNodeBase): multiline=True, default="", tooltip=( - "Negative Prompt Control prompts in positional order; each " - "segment shares hooks with the matching positive index, " - "and global text fills missing negative regions." + "Negative Prompt Control prompts separated by [SEP] or " + "[SEP|name] in positional order; each segment shares hooks " + "with the matching positive index, and global text fills " + "missing regions." ), ), _comfy_io.String.Input( "separator", - default="[SEP]", - tooltip="Text marker that splits prompts into per-SEGS entries.", + default=DEFAULT_PROMPT_BATCH_SEPARATOR, + tooltip=( + "Text marker that splits prompts into per-SEGS entries. " + "With the default [SEP], use [SEP|name] to add an " + "organizational label." + ), ), ], outputs=[ diff --git a/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py b/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py index ca4d234..7ba6d70 100644 --- a/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py +++ b/simple_syrup/nodes_v3/schedule_and_encode_prompts_with_prompt_control.py @@ -49,9 +49,9 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): enable_expand=True, category="SimpleSyrup/Conditioning", description=( - "Schedules Prompt-Control LoRAs and encodes prompts. With [SEP], " - "both sides are matched using global text for missing regions and " - "share each segment's LoRA hooks." + "Schedules Prompt-Control LoRAs and encodes prompts. With [SEP] " + "or [SEP|name], both sides are matched using global text for " + "missing regions and share each segment's LoRA hooks." ), inputs=[ _comfy_io.Model.Input( @@ -85,9 +85,9 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): multiline=False, default="", tooltip=( - "Positive Prompt-Control text; [SEP] creates ordered " - "conditioning entries, and global text fills missing " - "positive regions." + "Positive Prompt-Control text; [SEP] or [SEP|name] creates " + "ordered conditioning entries, and global text fills " + "missing positive regions." ), ), _comfy_io.String.Input( @@ -95,9 +95,9 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase): multiline=False, default="", tooltip=( - "Negative Prompt-Control text; [SEP] creates ordered " - "conditioning entries, and global text fills missing " - "negative regions." + "Negative Prompt-Control text; [SEP] or [SEP|name] creates " + "ordered conditioning entries, and global text fills " + "missing negative regions." ), ), ], diff --git a/simple_syrup/runtime/prompt_control_schedule_encode_graph.py b/simple_syrup/runtime/prompt_control_schedule_encode_graph.py index 03aa60b..43dbb76 100644 --- a/simple_syrup/runtime/prompt_control_schedule_encode_graph.py +++ b/simple_syrup/runtime/prompt_control_schedule_encode_graph.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import Any +from ..domain.prompt_batch_parser import DEFAULT_PROMPT_BATCH_SEPARATOR from ..domain.prompt_control_prompt import PreparedPromptSide, apply_encode_style from ..services.prompt_control_segment_planning_service import ( PromptControlSegmentPlan, @@ -22,7 +23,6 @@ PROMPT_CONTROL_MISSING_MESSAGE = ( "Schedule & Encode Prompts requires comfyui-prompt-control. " "Install Prompt Control or remove this node from the workflow." ) -PROMPT_BATCH_SEPARATOR = "[SEP]" class PromptControlScheduleEncodeGraphBuilder: @@ -44,7 +44,7 @@ class PromptControlScheduleEncodeGraphBuilder: plan = self.planning_service_class().prepare( positive_prompt=positive_prompt, negative_prompt=negative_prompt, - separator=PROMPT_BATCH_SEPARATOR, + separator=DEFAULT_PROMPT_BATCH_SEPARATOR, ) adapter = self.graph_adapter_class.load(PROMPT_CONTROL_MISSING_MESSAGE) expand: dict[str, dict[str, Any]] = {} diff --git a/simple_syrup/services/prompt_batch_encoding_service.py b/simple_syrup/services/prompt_batch_encoding_service.py index 59ccb50..5a71b60 100644 --- a/simple_syrup/services/prompt_batch_encoding_service.py +++ b/simple_syrup/services/prompt_batch_encoding_service.py @@ -8,7 +8,8 @@ from __future__ import annotations from typing import Any, Protocol -from ..domain.conditioning_batch import ConditioningBatch, split_prompt_batch +from ..domain.conditioning_batch import ConditioningBatch +from ..domain.prompt_batch_parser import split_prompt_batch from ..domain.prompt_segment_alignment import build_prompt_segment_alignment diff --git a/tests/test_conditioning_batch.py b/tests/test_conditioning_batch.py index 0ec9a6a..dccf7ee 100644 --- a/tests/test_conditioning_batch.py +++ b/tests/test_conditioning_batch.py @@ -12,37 +12,9 @@ from simple_syrup.domain.conditioning_batch import ( ConditioningBatch, batch_conditioning, select_conditioning, - split_prompt_batch, ) -def test_split_prompt_batch_without_separator_returns_single_chunk() -> None: - """Plain prompt text remains one prompt entry.""" - - assert split_prompt_batch("cat", "[SEP]") == ("cat",) - - -def test_split_prompt_batch_trims_separator_whitespace() -> None: - """Whitespace around separators does not become prompt text.""" - - assert split_prompt_batch("cat [SEP] dog", "[SEP]") == ("cat", "dog") - assert split_prompt_batch("cat[SEP]dog", "[SEP]") == ("cat", "dog") - - -def test_split_prompt_batch_preserves_blank_prompt_and_empty_chunks() -> None: - """Blank prompts and trailing separator chunks remain explicit entries.""" - - assert split_prompt_batch("", "[SEP]") == ("",) - assert split_prompt_batch("cat [SEP]", "[SEP]") == ("cat", "") - - -def test_split_prompt_batch_rejects_empty_separator() -> None: - """An empty separator would split between every character.""" - - with pytest.raises(ValueError, match="separator must not be empty"): - split_prompt_batch("cat", "") - - def test_conditioning_batch_requires_entries() -> None: """A batch must contain at least one selectable entry.""" diff --git a/tests/test_encode_prompt_batch_node.py b/tests/test_encode_prompt_batch_node.py index fc1e462..9f561bc 100644 --- a/tests/test_encode_prompt_batch_node.py +++ b/tests/test_encode_prompt_batch_node.py @@ -26,6 +26,7 @@ def test_encode_prompt_batch_contract() -> None: assert EncodePromptBatch.RETURN_NAMES == ("positive", "negative") assert EncodePromptBatch.CATEGORY == "SimpleSyrup/Conditioning" assert "global" in EncodePromptBatch.DESCRIPTION.lower() + assert "[sep|name]" in EncodePromptBatch.DESCRIPTION.lower() assert list(inputs["required"]) == [ "clip", "positive_prompt", @@ -36,6 +37,7 @@ def test_encode_prompt_batch_contract() -> None: assert inputs["required"]["positive_prompt"][1]["default"] == "" assert inputs["required"]["negative_prompt"][1]["default"] == "" assert inputs["required"]["separator"][1]["default"] == "[SEP]" + assert "[sep|name]" in inputs["required"]["separator"][1]["tooltip"].lower() assert "global" in inputs["required"]["positive_prompt"][1]["tooltip"].lower() assert "global" in inputs["required"]["negative_prompt"][1]["tooltip"].lower() @@ -60,6 +62,26 @@ def test_encode_prompt_batch_aligns_missing_negative_chunks_to_global( assert negative.entries == ("clip:blur", "clip:blur") +def test_encode_prompt_batch_accepts_named_default_separators( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Organizational separator names do not enter encoded prompt text.""" + + monkeypatch.setattr(EncodePromptBatch, "encoder_class", _FakeEncoder) + + positive, negative = EncodePromptBatch().encode( + clip="clip", + positive_prompt="global [SEP|Sky] clouds [SEP|Ground] field", + negative_prompt="blur", + separator="[SEP]", + ) + + assert isinstance(positive, ConditioningBatch) + assert isinstance(negative, ConditioningBatch) + assert positive.entries == ("clip:global", "clip:clouds", "clip:field") + assert negative.entries == ("clip:blur", "clip:blur", "clip:blur") + + def test_encode_prompt_batch_encodes_blank_and_empty_chunks( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_encode_prompt_batch_with_prompt_control_node.py b/tests/test_encode_prompt_batch_with_prompt_control_node.py index 8bb31d5..964bdca 100644 --- a/tests/test_encode_prompt_batch_with_prompt_control_node.py +++ b/tests/test_encode_prompt_batch_with_prompt_control_node.py @@ -21,6 +21,7 @@ def test_prompt_control_prompt_batch_node_schema() -> None: assert schema.enable_expand is True assert schema.category == "SimpleSyrup/Conditioning" assert "global" in schema.description.lower() + assert "[sep|name]" in schema.description.lower() assert [output.io_type for output in schema.outputs] == [ "CONDITIONING_BATCH", "CONDITIONING_BATCH", @@ -49,5 +50,6 @@ def test_prompt_control_prompt_batch_input_types() -> None: ] assert inputs["required"]["clip"][0] == "CLIP" assert inputs["required"]["separator"][1]["default"] == "[SEP]" + assert "[sep|name]" in inputs["required"]["separator"][1]["tooltip"].lower() assert "global" in inputs["required"]["positive_prompt"][1]["tooltip"].lower() assert "global" in inputs["required"]["negative_prompt"][1]["tooltip"].lower() diff --git a/tests/test_prompt_batch_parser.py b/tests/test_prompt_batch_parser.py new file mode 100644 index 0000000..84e1d1a --- /dev/null +++ b/tests/test_prompt_batch_parser.py @@ -0,0 +1,86 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Test prompt-batch separator parsing behavior.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.prompt_batch_parser import split_prompt_batch + + +def test_split_prompt_batch_without_separator_returns_single_chunk() -> None: + """Keep plain prompt text as one prompt entry.""" + + assert split_prompt_batch("cat", "[SEP]") == ("cat",) + + +def test_split_prompt_batch_trims_separator_whitespace() -> None: + """Exclude whitespace surrounding separators from prompt text.""" + + assert split_prompt_batch("cat [SEP] dog", "[SEP]") == ("cat", "dog") + assert split_prompt_batch("cat[SEP]dog", "[SEP]") == ("cat", "dog") + + +def test_split_prompt_batch_preserves_blank_prompt_and_empty_chunks() -> None: + """Keep blank prompts and trailing separator chunks as explicit entries.""" + + assert split_prompt_batch("", "[SEP]") == ("",) + assert split_prompt_batch("cat [SEP]", "[SEP]") == ("cat", "") + + +def test_split_prompt_batch_treats_custom_separator_as_literal_text() -> None: + """Escape regex syntax in a configured custom separator.""" + + assert split_prompt_batch("cat .+ dog", ".+") == ("cat", "dog") + + +def test_split_prompt_batch_accepts_named_default_separators() -> None: + """Discard organizational names while preserving prompt order.""" + + assert split_prompt_batch( + "global [SEP|Sky] clouds [SEP] field [SEP|Subject 2] person", + "[SEP]", + ) == ("global", "clouds", "field", "person") + + +def test_split_prompt_batch_preserves_empty_chunk_after_named_separator() -> None: + """Keep a trailing named separator as an explicit blank prompt entry.""" + + assert split_prompt_batch("cat [SEP|Unused region]", "[SEP]") == ("cat", "") + + +@pytest.mark.parametrize( + "marker", + ( + "[SEP|]", + "[SEP|Sky", + "[SEP|Sky\nClouds]", + "[sep|Sky]", + ), +) +def test_split_prompt_batch_keeps_malformed_or_wrong_case_markers( + marker: str, +) -> None: + """Leave text untouched when it is not a valid default separator marker.""" + + prompt = f"cat {marker} dog" + + assert split_prompt_batch(prompt, "[SEP]") == (prompt,) + + +def test_split_prompt_batch_does_not_extend_custom_separator_grammar() -> None: + """Recognize named markers only when the configured separator is `[SEP]`.""" + + prompt = "cat [SEP|Sky] dog" + + assert split_prompt_batch(prompt, "---") == (prompt,) + + +def test_split_prompt_batch_rejects_empty_separator() -> None: + """Reject a separator that would split between every character.""" + + with pytest.raises(ValueError, match="separator must not be empty"): + split_prompt_batch("cat", "") diff --git a/tests/test_prompt_control_batch_graph.py b/tests/test_prompt_control_batch_graph.py index d901adb..4512e81 100644 --- a/tests/test_prompt_control_batch_graph.py +++ b/tests/test_prompt_control_batch_graph.py @@ -86,6 +86,25 @@ def test_prompt_control_batch_graph_builds_pack_chain_for_multiple_chunks( assert output.args[1] == ["BATCH.0.5.2", 0] +def test_prompt_control_batch_graph_encodes_named_separator_chunks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Build one Prompt Control encoding per named default-separator chunk.""" + + calls = _install_fake_prompt_control(monkeypatch) + graph_utils = import_module("comfy_execution.graph_utils") + graph_utils.GraphBuilder.set_default_prefix("NAMED", 0, 0) + + PromptControlBatchGraphBuilder().build( + clip=[0, 0], + positive_prompt="global [SEP|Sky] clouds", + negative_prompt="blur [SEP|Sky] haze", + separator="[SEP]", + ) + + assert [call["text"] for call in calls] == ["global", "clouds", "blur", "haze"] + + def test_prompt_control_batch_graph_attaches_segment_local_lora_hooks( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_prompt_control_schedule_encode_graph.py b/tests/test_prompt_control_schedule_encode_graph.py index e4659bf..f11e9a0 100644 --- a/tests/test_prompt_control_schedule_encode_graph.py +++ b/tests/test_prompt_control_schedule_encode_graph.py @@ -96,6 +96,30 @@ def test_schedule_encode_graph_packs_both_sides_to_matched_segment_counts( ] +def test_schedule_encode_graph_uses_named_default_separators( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Recognize named separators on the fixed-default scheduling path.""" + + calls = _install_fake_prompt_control(monkeypatch) + + PromptControlScheduleEncodeGraphBuilder().build( + model=["model", 0], + clip=["clip", 0], + positive_prompt="global [SEP|Sky] clouds [SEP|Ground] field", + negative_prompt="blur", + ) + + assert [call["text"] for call in calls["encode"]] == [ + "global", + "clouds", + "field", + "blur", + "blur", + "blur", + ] + + def test_schedule_encode_graph_matches_lora_region_on_both_cfg_sides( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_prompt_control_segment_planning_service.py b/tests/test_prompt_control_segment_planning_service.py index 4b62549..54483ea 100644 --- a/tests/test_prompt_control_segment_planning_service.py +++ b/tests/test_prompt_control_segment_planning_service.py @@ -38,6 +38,30 @@ def test_planner_combines_lora_tags_only_at_aligned_indexes() -> None: ] +def test_planner_aligns_lora_hooks_across_named_separators() -> None: + """Treat separator names as comments while aligning segment-local hooks.""" + + plan = PromptControlSegmentPlanningService().prepare( + positive_prompt=( + "global [SEP|Sky] clouds [SEP|Ground] field " + ), + negative_prompt="bad [SEP|Sky] haze", + separator="[SEP]", + ) + + assert [chunk.text for chunk in plan.positive.chunks] == [ + "global", + "clouds ", + "field ", + ] + assert [chunk.text for chunk in plan.negative.chunks] == ["bad", "haze", "bad"] + assert [hook.lora_tags for hook in plan.hooks] == [ + "", + "", + "", + ] + + def test_planner_preserves_empties_and_fills_missing_side_from_global() -> None: """Authored empties remain while missing negative positions reuse global text.""" diff --git a/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py b/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py index 3f499bd..bba9d1d 100644 --- a/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py +++ b/tests/test_schedule_and_encode_prompts_with_prompt_control_node.py @@ -33,6 +33,7 @@ def test_legacy_schedule_and_encode_prompt_control_node_contract() -> None: ) assert LegacyScheduleAndEncode.FUNCTION == "execute" assert LegacyScheduleAndEncode.CATEGORY == "SimpleSyrup/Conditioning" + assert "[sep|name]" in LegacyScheduleAndEncode.DESCRIPTION.lower() assert list(inputs["required"]) == [ "model", "clip", @@ -46,6 +47,7 @@ def test_legacy_schedule_and_encode_prompt_control_node_contract() -> None: assert inputs["optional"]["encode_style"][1]["forceInput"] is True assert inputs["required"]["positive_prompt"][1]["multiline"] is False assert inputs["required"]["negative_prompt"][1]["multiline"] is False + assert "[sep|name]" in inputs["required"]["positive_prompt"][1]["tooltip"].lower() def test_legacy_schedule_and_encode_prompt_control_execute_delegates( @@ -141,6 +143,7 @@ def test_schedule_and_encode_prompt_control_node_schema() -> None: assert schema.enable_expand is True assert schema.category == "SimpleSyrup/Conditioning" assert "global" in schema.description.lower() + assert "[sep|name]" in schema.description.lower() assert [output.io_type for output in schema.outputs] == [ "MODEL", "CONDITIONING,CONDITIONING_BATCH", @@ -189,6 +192,7 @@ def test_schedule_and_encode_prompt_control_input_types() -> None: assert inputs["optional"]["encode_style"][1]["forceInput"] is True assert inputs["required"]["positive_prompt"][1]["multiline"] is False assert inputs["required"]["negative_prompt"][1]["multiline"] is False + assert "[sep|name]" in inputs["required"]["positive_prompt"][1]["tooltip"].lower() def test_schedule_and_encode_prompt_control_execute_delegates( From 4a6b8eb15d8c066934c76a9fd96636d845f5ebef Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Fri, 7 Aug 2026 20:51:18 -0400 Subject: [PATCH 36/40] feat(loaders): add automatic FLUX model loaders --- simple_syrup/domain/flux_profiles.py | 58 +++ simple_syrup/nodes/simple_load_anima.py | 2 + simple_syrup/nodes_v3/__init__.py | 4 + simple_syrup/nodes_v3/simple_load_flux.py | 161 +++++++++ simple_syrup/nodes_v3/simple_load_flux2.py | 150 ++++++++ simple_syrup/runtime/anima_artifacts.py | 39 ++ simple_syrup/runtime/anima_loader.py | 3 +- simple_syrup/runtime/auto_model_artifact.py | 23 ++ simple_syrup/runtime/auto_model_cache.py | 37 +- simple_syrup/runtime/auto_model_resolver.py | 51 ++- .../runtime/diffusion_model_loader.py | 69 ++++ simple_syrup/runtime/flux_artifacts.py | 119 +++++++ simple_syrup/runtime/flux_model_inspector.py | 54 +++ simple_syrup/runtime/model_catalog.py | 44 --- simple_syrup/runtime/model_downloads.py | 20 +- simple_syrup/runtime/text_encoder_loader.py | 67 ++++ simple_syrup/services/flux2_loader_service.py | 112 ++++++ .../services/flux_loader_boundaries.py | 60 ++++ .../services/flux_loader_components.py | 97 +++++ simple_syrup/services/flux_loader_service.py | 97 +++++ tests/test_anima_loader.py | 190 +++++++++- tests/test_auto_model_cache.py | 6 + tests/test_auto_model_resolver.py | 56 ++- tests/test_flux_artifacts.py | 59 +++ tests/test_flux_loader_services.py | 336 ++++++++++++++++++ tests/test_flux_model_inspector.py | 61 ++++ tests/test_flux_profiles.py | 62 ++++ tests/test_flux_runtime_loaders.py | 293 +++++++++++++++ tests/test_model_catalog.py | 4 +- tests/test_model_downloads.py | 85 ++++- tests/test_registration.py | 2 + tests/test_simple_load_anima_node.py | 29 +- tests/test_simple_load_flux_nodes.py | 160 +++++++++ 33 files changed, 2543 insertions(+), 67 deletions(-) create mode 100644 simple_syrup/domain/flux_profiles.py create mode 100644 simple_syrup/nodes_v3/simple_load_flux.py create mode 100644 simple_syrup/nodes_v3/simple_load_flux2.py create mode 100644 simple_syrup/runtime/anima_artifacts.py create mode 100644 simple_syrup/runtime/auto_model_artifact.py create mode 100644 simple_syrup/runtime/diffusion_model_loader.py create mode 100644 simple_syrup/runtime/flux_artifacts.py create mode 100644 simple_syrup/runtime/flux_model_inspector.py create mode 100644 simple_syrup/runtime/text_encoder_loader.py create mode 100644 simple_syrup/services/flux2_loader_service.py create mode 100644 simple_syrup/services/flux_loader_boundaries.py create mode 100644 simple_syrup/services/flux_loader_components.py create mode 100644 simple_syrup/services/flux_loader_service.py create mode 100644 tests/test_flux_artifacts.py create mode 100644 tests/test_flux_loader_services.py create mode 100644 tests/test_flux_model_inspector.py create mode 100644 tests/test_flux_profiles.py create mode 100644 tests/test_flux_runtime_loaders.py create mode 100644 tests/test_simple_load_flux_nodes.py diff --git a/simple_syrup/domain/flux_profiles.py b/simple_syrup/domain/flux_profiles.py new file mode 100644 index 0000000..3ff306b --- /dev/null +++ b/simple_syrup/domain/flux_profiles.py @@ -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) diff --git a/simple_syrup/nodes/simple_load_anima.py b/simple_syrup/nodes/simple_load_anima.py index 8fefa3f..250b702 100644 --- a/simple_syrup/nodes/simple_load_anima.py +++ b/simple_syrup/nodes/simple_load_anima.py @@ -68,6 +68,7 @@ class SimpleLoadAnima: _choices_with_auto(folder_paths.get_filename_list("text_encoders")), { "default": AUTO_CHOICE, + "advanced": True, "tooltip": ( "Qwen text encoder used for Anima prompt understanding. " "Auto selects the expected model." @@ -89,6 +90,7 @@ class SimpleLoadAnima: _choices_with_auto(vae_choices(folder_paths)), { "default": AUTO_CHOICE, + "advanced": True, "tooltip": ( "VAE used to decode Anima latents. Auto selects the " "expected Qwen image VAE." diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index 193f16f..27b7817 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -52,6 +52,8 @@ def get_nodes() -> list[type[object]]: from .mask_to_segs import MaskToSEGSV3 from .scale_factor import ScaleFactorV3 from .simple_load_checkpoint import SimpleLoadCheckpointV3 + from .simple_load_flux import SimpleLoadFluxV3 + from .simple_load_flux2 import SimpleLoadFlux2V3 from .tag_segs_with_external_llm import TagSEGSWithExternalLLMV3 from .tag_segs_with_wd14 import TagSEGSWithWD14V3 from .tile_and_tag_segs import TileAndTagSEGSV3 @@ -95,6 +97,8 @@ def get_nodes() -> list[type[object]]: SimpleLoadAnimaV3, SimplePreviewSEGSV3, SimpleLoadCheckpointV3, + SimpleLoadFluxV3, + SimpleLoadFlux2V3, SimpleVAEEncodeV3, TagSEGSWithExternalLLMV3, TagSEGSWithWD14V3, diff --git a/simple_syrup/nodes_v3/simple_load_flux.py b/simple_syrup/nodes_v3/simple_load_flux.py new file mode 100644 index 0000000..0c3ef84 --- /dev/null +++ b/simple_syrup/nodes_v3/simple_load_flux.py @@ -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 + +"""Expose cohesive FLUX.1 model loading through Comfy's v3 API.""" + +from __future__ import annotations + +import importlib +from types import ModuleType +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes import tooltips +from ..runtime.diffusion_model_loader import DIFFUSION_WEIGHT_DTYPES +from ..runtime.model_downloads import ComfyProgressReporter +from ..runtime.text_encoder_loader import TEXT_ENCODER_DEVICES +from ..runtime.vae_loader import vae_choices +from ..services.flux_loader_components import AUTO_CHOICE +from ..services.flux_loader_service import FluxLoaderService + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = importlib.import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = ( + None if TYPE_CHECKING else importlib.import_module("comfy_api.latest").io +) + + +class SimpleLoadFluxV3(_ComfyNodeBase): + """Load FLUX.1 diffusion, dual text encoders, and VAE as one node.""" + + _service = FluxLoaderService() + + @classmethod + def define_schema(cls) -> Any: + """Declare the separate FLUX.1 loader schema.""" + + folder_paths = _folder_paths() + text_encoder_choices = _choices_with_auto( + list(folder_paths.get_filename_list("text_encoders")) + ) + return _comfy_io.Schema( + node_id="SimpleSyrup.SimpleLoadFlux", + display_name="Simple Load FLUX", + category="SimpleSyrup/Loaders", + description=( + "Loads FLUX.1 with CLIP-L, T5-XXL, and its VAE; automatic " + "components are downloaded from checksum-pinned Hugging Face files." + ), + search_aliases=["flux", "flux 1", "load flux"], + inputs=[ + _comfy_io.Combo.Input( + "diffusion_model", + options=list(folder_paths.get_filename_list("diffusion_models")), + tooltip=( + "FLUX.1 diffusion model to load. This node never downloads " + "the diffusion model." + ), + ), + _comfy_io.Combo.Input( + "diffusion_weight_dtype", + options=list(DIFFUSION_WEIGHT_DTYPES), + default="default", + advanced=True, + tooltip=( + "Weight precision for the diffusion model; FP8 uses less " + "memory but can slightly change results." + ), + ), + _comfy_io.Combo.Input( + "clip_l", + options=text_encoder_choices, + default=AUTO_CHOICE, + advanced=True, + tooltip=( + "CLIP-L encoder for FLUX.1 prompts. Auto finds the exact " + "file or downloads it while Comfy shows node progress." + ), + ), + _comfy_io.Combo.Input( + "t5_xxl", + options=text_encoder_choices, + default=AUTO_CHOICE, + advanced=True, + tooltip=( + "T5-XXL encoder for FLUX.1 prompts. Auto may download the " + "large FP16 file and reports progress in Comfy." + ), + ), + _comfy_io.Combo.Input( + "text_encoder_device", + options=list(TEXT_ENCODER_DEVICES), + default="default", + advanced=True, + tooltip=( + "Device for both text encoders; CPU saves GPU memory but " + "makes prompt encoding slower." + ), + ), + _comfy_io.Combo.Input( + "vae", + options=_choices_with_auto(vae_choices(folder_paths)), + default=AUTO_CHOICE, + advanced=True, + tooltip=( + "VAE used to decode FLUX.1 latents. Auto finds or downloads " + "the checksum-pinned FLUX VAE with visible node progress." + ), + ), + ], + outputs=[ + _comfy_io.Model.Output("model", tooltip=tooltips.MODEL_OUTPUT), + _comfy_io.Clip.Output("clip", tooltip=tooltips.CLIP_OUTPUT), + _comfy_io.Vae.Output("vae", tooltip=tooltips.VAE_OUTPUT), + ], + ) + + @classmethod + def execute( + cls, + diffusion_model: str, + diffusion_weight_dtype: str, + clip_l: str, + t5_xxl: str, + text_encoder_device: str, + vae: str, + ) -> tuple[object, object, object]: + """Load and return FLUX.1 MODEL, CLIP, and VAE objects.""" + + return cls._service.load_models( + diffusion_model=diffusion_model, + diffusion_weight_dtype=diffusion_weight_dtype, + clip_l=clip_l, + t5_xxl=t5_xxl, + text_encoder_device=text_encoder_device, + vae=vae, + progress=ComfyProgressReporter(), + ) + + +def _choices_with_auto(choices: list[str]) -> list[str]: + """Return deduplicated choices with automatic selection first.""" + + return [AUTO_CHOICE, *(choice for choice in choices if choice != AUTO_CHOICE)] + + +def _folder_paths() -> ModuleType: + """Import ComfyUI folder paths lazily for schema declaration.""" + + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + return module diff --git a/simple_syrup/nodes_v3/simple_load_flux2.py b/simple_syrup/nodes_v3/simple_load_flux2.py new file mode 100644 index 0000000..1189fd0 --- /dev/null +++ b/simple_syrup/nodes_v3/simple_load_flux2.py @@ -0,0 +1,150 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Expose cohesive FLUX.2 model loading through Comfy's v3 API.""" + +from __future__ import annotations + +import importlib +from types import ModuleType +from typing import TYPE_CHECKING, Any, ClassVar + +from ..nodes import tooltips +from ..runtime.diffusion_model_loader import DIFFUSION_WEIGHT_DTYPES +from ..runtime.model_downloads import ComfyProgressReporter +from ..runtime.text_encoder_loader import TEXT_ENCODER_DEVICES +from ..runtime.vae_loader import vae_choices +from ..services.flux2_loader_service import Flux2LoaderService +from ..services.flux_loader_components import AUTO_CHOICE + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = importlib.import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = ( + None if TYPE_CHECKING else importlib.import_module("comfy_api.latest").io +) + + +class SimpleLoadFlux2V3(_ComfyNodeBase): + """Load FLUX.2 diffusion, one profile-specific encoder, and VAE.""" + + _service = Flux2LoaderService() + + @classmethod + def define_schema(cls) -> Any: + """Declare the separate FLUX.2 loader schema.""" + + folder_paths = _folder_paths() + return _comfy_io.Schema( + node_id="SimpleSyrup.SimpleLoadFlux2", + display_name="Simple Load FLUX.2", + category="SimpleSyrup/Loaders", + description=( + "Loads FLUX.2 with its structurally matched text encoder and common " + "VAE; automatic components download checksum-pinned Hugging Face " + "files when needed." + ), + search_aliases=["flux 2", "flux2", "klein", "load flux 2"], + inputs=[ + _comfy_io.Combo.Input( + "diffusion_model", + options=list(folder_paths.get_filename_list("diffusion_models")), + tooltip=( + "FLUX.2 diffusion model to load. This node never downloads " + "the diffusion model." + ), + ), + _comfy_io.Combo.Input( + "diffusion_weight_dtype", + options=list(DIFFUSION_WEIGHT_DTYPES), + default="default", + advanced=True, + tooltip=( + "Weight precision for the diffusion model; FP8 uses less " + "memory but can slightly change results." + ), + ), + _comfy_io.Combo.Input( + "text_encoder", + options=_choices_with_auto( + list(folder_paths.get_filename_list("text_encoders")) + ), + default=AUTO_CHOICE, + advanced=True, + tooltip=( + "Text encoder for FLUX.2. Auto detects dev, Klein 4B, or " + "Klein 9B/KV from the loaded model and reports downloads " + "through Comfy node progress." + ), + ), + _comfy_io.Combo.Input( + "text_encoder_device", + options=list(TEXT_ENCODER_DEVICES), + default="default", + advanced=True, + tooltip=( + "Device for the text encoder; CPU saves GPU memory but makes " + "prompt encoding slower." + ), + ), + _comfy_io.Combo.Input( + "vae", + options=_choices_with_auto(vae_choices(folder_paths)), + default=AUTO_CHOICE, + advanced=True, + tooltip=( + "VAE used to decode FLUX.2 latents. Auto finds or downloads " + "the common checksum-pinned VAE with visible node progress." + ), + ), + ], + outputs=[ + _comfy_io.Model.Output("model", tooltip=tooltips.MODEL_OUTPUT), + _comfy_io.Clip.Output("clip", tooltip=tooltips.CLIP_OUTPUT), + _comfy_io.Vae.Output("vae", tooltip=tooltips.VAE_OUTPUT), + ], + ) + + @classmethod + def execute( + cls, + diffusion_model: str, + diffusion_weight_dtype: str, + text_encoder: str, + text_encoder_device: str, + vae: str, + ) -> tuple[object, object, object]: + """Load and return FLUX.2 MODEL, CLIP, and VAE objects.""" + + return cls._service.load_models( + diffusion_model=diffusion_model, + diffusion_weight_dtype=diffusion_weight_dtype, + text_encoder=text_encoder, + text_encoder_device=text_encoder_device, + vae=vae, + progress=ComfyProgressReporter(), + ) + + +def _choices_with_auto(choices: list[str]) -> list[str]: + """Return deduplicated choices with automatic selection first.""" + + return [AUTO_CHOICE, *(choice for choice in choices if choice != AUTO_CHOICE)] + + +def _folder_paths() -> ModuleType: + """Import ComfyUI folder paths lazily for schema declaration.""" + + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + return module diff --git a/simple_syrup/runtime/anima_artifacts.py b/simple_syrup/runtime/anima_artifacts.py new file mode 100644 index 0000000..f0f3746 --- /dev/null +++ b/simple_syrup/runtime/anima_artifacts.py @@ -0,0 +1,39 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Declare the trusted automatic artifacts used by Anima.""" + +from __future__ import annotations + +from .auto_model_artifact import AutoModelArtifact + +ANIMA_QWEN_TEXT_ENCODER = AutoModelArtifact( + cache_id="anima_qwen_text_encoder", + filename="qwen_3_06b_base.safetensors", + folder_name="text_encoders", + canonical_subfolder="qwen", + source_url=( + "https://huggingface.co/circlestone-labs/Anima/resolve/main/" + "split_files/text_encoders/qwen_3_06b_base.safetensors" + ), + source_repo="circlestone-labs/Anima", + description="Anima Qwen3 0.6B text encoder", + sha256="cd2a512003e2f9f3cd3c32a9c3573f820bb28c940f73c57b1ddaa983d9223eba", +) + +ANIMA_QWEN_VAE = AutoModelArtifact( + cache_id="anima_qwen_vae", + filename="qwen_image_vae.safetensors", + folder_name="vae", + canonical_subfolder="qwen", + source_url=( + "https://huggingface.co/circlestone-labs/Anima/resolve/main/" + "split_files/vae/qwen_image_vae.safetensors" + ), + source_repo="circlestone-labs/Anima", + description="Anima Qwen Image VAE", + sha256="a70580f0213e67967ee9c95f05bb400e8fb08307e017a924bf3441223e023d1f", +) + +ANIMA_AUTO_ARTIFACTS = (ANIMA_QWEN_TEXT_ENCODER, ANIMA_QWEN_VAE) diff --git a/simple_syrup/runtime/anima_loader.py b/simple_syrup/runtime/anima_loader.py index a812f99..9013e9b 100644 --- a/simple_syrup/runtime/anima_loader.py +++ b/simple_syrup/runtime/anima_loader.py @@ -13,8 +13,9 @@ from typing import Any, Protocol import torch +from .anima_artifacts import ANIMA_QWEN_TEXT_ENCODER, ANIMA_QWEN_VAE +from .auto_model_artifact import AutoModelArtifact from .auto_model_resolver import AutoModelResolution, AutoModelResolver -from .model_catalog import ANIMA_QWEN_TEXT_ENCODER, ANIMA_QWEN_VAE, AutoModelArtifact from .model_downloads import ProgressReporter from .vae_loader import VaeLoaderService, load_vae_path diff --git a/simple_syrup/runtime/auto_model_artifact.py b/simple_syrup/runtime/auto_model_artifact.py new file mode 100644 index 0000000..e312464 --- /dev/null +++ b/simple_syrup/runtime/auto_model_artifact.py @@ -0,0 +1,23 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Define trusted artifacts eligible for automatic local resolution.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AutoModelArtifact: + """Describe one checksum-pinned artifact and its canonical destination.""" + + cache_id: str + filename: str + folder_name: str + canonical_subfolder: str + source_url: str + source_repo: str + description: str + sha256: str diff --git a/simple_syrup/runtime/auto_model_cache.py b/simple_syrup/runtime/auto_model_cache.py index ea7ce5e..ae88fc9 100644 --- a/simple_syrup/runtime/auto_model_cache.py +++ b/simple_syrup/runtime/auto_model_cache.py @@ -33,17 +33,24 @@ class AutoModelCacheEntry: path: Path source: CacheSource sha256: str + file_size: int | None = None + modified_time_ns: int | None = None - def to_payload(self) -> dict[str, str]: + def to_payload(self) -> dict[str, str | int]: """Return this cache entry as a JSON-serializable payload.""" - return { + payload: dict[str, str | int] = { "folder_name": self.folder_name, "filename": self.filename, "path": str(self.path), "source": self.source, "sha256": self.sha256, } + if self.file_size is not None: + payload["file_size"] = self.file_size + if self.modified_time_ns is not None: + payload["modified_time_ns"] = self.modified_time_ns + return payload @classmethod def from_payload( @@ -62,12 +69,20 @@ class AutoModelCacheEntry: path = _required_string(payload, cache_id, "path") source = _required_source(payload, cache_id) sha256 = _required_string(payload, cache_id, "sha256") + file_size = _optional_nonnegative_integer(payload, cache_id, "file_size") + modified_time_ns = _optional_nonnegative_integer( + payload, + cache_id, + "modified_time_ns", + ) return cls( folder_name=folder_name, filename=filename, path=Path(path), source=source, sha256=sha256, + file_size=file_size, + modified_time_ns=modified_time_ns, ) @@ -200,6 +215,24 @@ def _required_source(payload: dict[Any, Any], cache_id: str) -> CacheSource: return cast(CacheSource, value) +def _optional_nonnegative_integer( + payload: dict[Any, Any], + cache_id: str, + field_name: str, +) -> int | None: + """Return one optional nonnegative integer cache field.""" + + value = payload.get(field_name) + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise AutoModelCacheError( + f"Auto model cache entry '{cache_id}' field '{field_name}' " + "must be a nonnegative integer." + ) + return cast(int, value) + + def _user_directory(folder_paths: ModuleType) -> Path: """Return ComfyUI's user directory using the host's current conventions.""" diff --git a/simple_syrup/runtime/auto_model_resolver.py b/simple_syrup/runtime/auto_model_resolver.py index 634c643..040fcba 100644 --- a/simple_syrup/runtime/auto_model_resolver.py +++ b/simple_syrup/runtime/auto_model_resolver.py @@ -12,13 +12,14 @@ from types import ModuleType from typing import Protocol from ..shared.logging import get_logger +from .auto_model_artifact import AutoModelArtifact from .auto_model_cache import AutoModelCache, AutoModelCacheEntry, CacheSource -from .model_catalog import AutoModelArtifact from .model_downloads import ( DownloadRequest, DownloadResult, ModelDownloader, ProgressReporter, + sha256_file, ) from .model_folders import get_model_folder_paths @@ -69,6 +70,8 @@ class AutoModelResolver: entries = self._cache.load() cached = entries.get(artifact.cache_id) if cached is not None and self._cache_entry_is_valid(cached, artifact): + if cached.file_size is None or cached.modified_time_ns is None: + self._save_resolution(artifact, cached.path, cached.source) LOGGER.info( "auto model cache hit", extra={"cache_id": artifact.cache_id, "path": str(cached.path)}, @@ -80,11 +83,7 @@ class AutoModelResolver: extra={"cache_id": artifact.cache_id, "path": str(cached.path)}, ) - found = find_model_by_basename( - artifact.folder_name, - artifact.filename, - self._folder_paths_module, - ) + found = find_model_artifact(artifact, self._folder_paths_module) if found is not None: self._save_resolution(artifact, found, "found") LOGGER.info( @@ -124,6 +123,7 @@ class AutoModelResolver: ) -> None: """Persist one successful automatic model resolution.""" + file_stat = path.stat() self._cache.save_entry( artifact.cache_id, AutoModelCacheEntry( @@ -132,6 +132,8 @@ class AutoModelResolver: path=path, source=source, sha256=artifact.sha256, + file_size=file_stat.st_size, + modified_time_ns=file_stat.st_mtime_ns, ), ) @@ -160,7 +162,42 @@ class AutoModelResolver: ) except ValueError: return False - return True + stat = entry.path.stat() + if entry.file_size is not None and entry.modified_time_ns is not None: + return ( + entry.file_size == stat.st_size + and entry.modified_time_ns == stat.st_mtime_ns + ) + return sha256_file(entry.path).lower() == artifact.sha256.lower() + + +def find_model_artifact( + artifact: AutoModelArtifact, + folder_paths_module: ModuleType | None = None, +) -> Path | None: + """Return the first same-named local file matching the catalog checksum.""" + + _validate_basename(artifact.filename) + for root in get_model_folder_paths(artifact.folder_name, folder_paths_module): + if not root.is_dir(): + continue + matches = sorted( + path for path in root.rglob(artifact.filename) if path.is_file() + ) + for match in matches: + if match.name != artifact.filename or not _path_is_under(match, root): + continue + if sha256_file(match).lower() == artifact.sha256.lower(): + return match + LOGGER.warning( + "same-named auto model artifact has a different checksum", + extra={ + "cache_id": artifact.cache_id, + "path": str(match), + "artifact_filename": artifact.filename, + }, + ) + return None def find_model_by_basename( diff --git a/simple_syrup/runtime/diffusion_model_loader.py b/simple_syrup/runtime/diffusion_model_loader.py new file mode 100644 index 0000000..5d83ed7 --- /dev/null +++ b/simple_syrup/runtime/diffusion_model_loader.py @@ -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 + +"""Load standalone diffusion models through ComfyUI's native policy.""" + +from __future__ import annotations + +import importlib +from types import ModuleType +from typing import Any + +import torch + +DIFFUSION_WEIGHT_DTYPES = ( + "default", + "fp8_e4m3fn", + "fp8_e4m3fn_fast", + "fp8_e5m2", +) + + +class DiffusionModelLoader: + """Adapt ComfyUI's standalone diffusion loader behind a typed boundary.""" + + def __init__(self, folder_paths_module: ModuleType | None = None) -> None: + """Create a loader with injectable ComfyUI folder paths.""" + + self._folder_paths_module = folder_paths_module + + def load(self, diffusion_model: str, weight_dtype: str) -> object: + """Load one diffusion model using a validated weight dtype.""" + + model_options = diffusion_model_options(weight_dtype) + model_path = self._folder_paths().get_full_path_or_raise( + "diffusion_models", + diffusion_model, + ) + comfy_sd: Any = importlib.import_module("comfy.sd") + return comfy_sd.load_diffusion_model( + model_path, + model_options=model_options, + ) + + def _folder_paths(self) -> ModuleType: + """Return the configured ComfyUI folder paths module.""" + + if self._folder_paths_module is not None: + return self._folder_paths_module + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + self._folder_paths_module = module + return module + + +def diffusion_model_options(weight_dtype: str) -> dict[str, object]: + """Return ComfyUI model options for a supported diffusion weight dtype.""" + + if weight_dtype not in DIFFUSION_WEIGHT_DTYPES: + valid = ", ".join(DIFFUSION_WEIGHT_DTYPES) + raise ValueError(f"diffusion_weight_dtype must be one of: {valid}.") + if weight_dtype == "fp8_e4m3fn": + return {"dtype": torch.float8_e4m3fn} + if weight_dtype == "fp8_e4m3fn_fast": + return {"dtype": torch.float8_e4m3fn, "fp8_optimizations": True} + if weight_dtype == "fp8_e5m2": + return {"dtype": torch.float8_e5m2} + return {} diff --git a/simple_syrup/runtime/flux_artifacts.py b/simple_syrup/runtime/flux_artifacts.py new file mode 100644 index 0000000..b883b10 --- /dev/null +++ b/simple_syrup/runtime/flux_artifacts.py @@ -0,0 +1,119 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Declare checksum-pinned automatic artifacts for FLUX and FLUX.2.""" + +from __future__ import annotations + +from ..domain.flux_profiles import Flux2TextEncoderProfile +from .auto_model_artifact import AutoModelArtifact + +FLUX_CLIP_L = AutoModelArtifact( + cache_id="flux_clip_l", + filename="clip_l.safetensors", + folder_name="text_encoders", + canonical_subfolder="flux", + source_url=( + "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/" + "6af2a98e3f615bdfa612fbd85da93d1ed5f69ef5/clip_l.safetensors" + ), + source_repo="comfyanonymous/flux_text_encoders", + description="FLUX CLIP-L text encoder", + sha256="660c6f5b1abae9dc498ac2d21e1347d2abdb0cf6c0c0c8576cd796491d9a6cdd", +) + +FLUX_T5_XXL = AutoModelArtifact( + cache_id="flux_t5_xxl", + filename="t5xxl_fp16.safetensors", + folder_name="text_encoders", + canonical_subfolder="flux", + source_url=( + "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/" + "6af2a98e3f615bdfa612fbd85da93d1ed5f69ef5/t5xxl_fp16.safetensors" + ), + source_repo="comfyanonymous/flux_text_encoders", + description="FLUX T5-XXL FP16 text encoder", + sha256="6e480b09fae049a72d2a8c5fbccb8d3e92febeb233bbe9dfe7256958a9167635", +) + +FLUX_VAE = AutoModelArtifact( + cache_id="flux_vae", + filename="ae.safetensors", + folder_name="vae", + canonical_subfolder="flux", + source_url=( + "https://huggingface.co/Comfy-Org/Lumina_Image_2.0_Repackaged/resolve/" + "22e393d707f2d13e736b1a461c958644258cd9d9/split_files/vae/" + "ae.safetensors" + ), + source_repo="Comfy-Org/Lumina_Image_2.0_Repackaged", + description="FLUX autoencoder VAE", + sha256="afc8e28272cd15db3919bacdb6918ce9c1ed22e96cb12c4d5ed0fba823529e38", +) + +FLUX2_DEV_TEXT_ENCODER = AutoModelArtifact( + cache_id="flux2_mistral_3_small_text_encoder", + filename="mistral_3_small_flux2_bf16.safetensors", + folder_name="text_encoders", + canonical_subfolder="flux2", + source_url=( + "https://huggingface.co/Comfy-Org/flux2-dev/resolve/" + "03d6521e6f6a47396b3f951cbea50f7e6c2f482e/split_files/text_encoders/" + "mistral_3_small_flux2_bf16.safetensors" + ), + source_repo="Comfy-Org/flux2-dev", + description="FLUX.2 dev Mistral 3 Small text encoder", + sha256="7d79902f60b1aeb3a6de2cfad02f4367b5e300a1387de3d03ac717cfa3df117c", +) + +FLUX2_KLEIN_4B_TEXT_ENCODER = AutoModelArtifact( + cache_id="flux2_klein_4b_qwen_text_encoder", + filename="qwen_3_4b.safetensors", + folder_name="text_encoders", + canonical_subfolder="flux2", + source_url=( + "https://huggingface.co/Comfy-Org/vae-text-encorder-for-flux-klein-4b/" + "resolve/a9e4ca87c16db4c4e1a16406a9ddb300ab0ae246/split_files/" + "text_encoders/qwen_3_4b.safetensors" + ), + source_repo="Comfy-Org/vae-text-encorder-for-flux-klein-4b", + description="FLUX.2 Klein 4B Qwen3 text encoder", + sha256="6c671498573ac2f7a5501502ccce8d2b08ea6ca2f661c458e708f36b36edfc5a", +) + +FLUX2_KLEIN_9B_TEXT_ENCODER = AutoModelArtifact( + cache_id="flux2_klein_9b_qwen_text_encoder", + filename="qwen_3_8b_fp8mixed.safetensors", + folder_name="text_encoders", + canonical_subfolder="flux2", + source_url=( + "https://huggingface.co/Comfy-Org/vae-text-encorder-for-flux-klein-9b/" + "resolve/23fbc8aa8b621f29f2249cd1bd9c47e5d0eebd83/split_files/" + "text_encoders/qwen_3_8b_fp8mixed.safetensors" + ), + source_repo="Comfy-Org/vae-text-encorder-for-flux-klein-9b", + description="FLUX.2 Klein 9B Qwen3 8B FP8-mixed text encoder", + sha256="abad16806e0cbabc54e0325d6565847443fe396d5f0be38bb3cd3fe75a1201d6", +) + +FLUX2_VAE = AutoModelArtifact( + cache_id="flux2_vae", + filename="flux2-vae.safetensors", + folder_name="vae", + canonical_subfolder="flux2", + source_url=( + "https://huggingface.co/Comfy-Org/flux2-dev/resolve/" + "03d6521e6f6a47396b3f951cbea50f7e6c2f482e/split_files/vae/" + "flux2-vae.safetensors" + ), + source_repo="Comfy-Org/flux2-dev", + description="FLUX.2 VAE", + sha256="d64f3a68e1cc4f9f4e29b6e0da38a0204fe9a49f2d4053f0ec1fa1ca02f9c4b5", +) + +FLUX2_TEXT_ENCODERS: dict[Flux2TextEncoderProfile, AutoModelArtifact] = { + Flux2TextEncoderProfile.DEV: FLUX2_DEV_TEXT_ENCODER, + Flux2TextEncoderProfile.KLEIN_4B: FLUX2_KLEIN_4B_TEXT_ENCODER, + Flux2TextEncoderProfile.KLEIN_9B: FLUX2_KLEIN_9B_TEXT_ENCODER, +} diff --git a/simple_syrup/runtime/flux_model_inspector.py b/simple_syrup/runtime/flux_model_inspector.py new file mode 100644 index 0000000..03928da --- /dev/null +++ b/simple_syrup/runtime/flux_model_inspector.py @@ -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 + +"""Inspect ComfyUI-loaded model configuration for structural FLUX metadata.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Protocol, runtime_checkable + +from ..domain.flux_profiles import FluxModelProfile, classify_flux_profile +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + + +@runtime_checkable +class ModelPatcherBoundary(Protocol): + """Expose the loaded model objects required for architecture inspection.""" + + def get_model_object(self, name: str) -> object: + """Return a named object owned by ComfyUI's model patcher.""" + + +class FluxModelInspector: + """Read ComfyUI's tensor-derived model configuration after model loading.""" + + def inspect(self, model: object) -> FluxModelProfile | None: + """Return a detected FLUX profile or None for unavailable metadata.""" + + if not isinstance(model, ModelPatcherBoundary): + return None + try: + model_config = model.get_model_object("model_config") + except (AttributeError, KeyError, TypeError, ValueError): + LOGGER.warning( + "loaded model does not expose inspectable model configuration" + ) + return None + + unet_config = getattr(model_config, "unet_config", None) + if not isinstance(unet_config, Mapping): + return None + image_model_value = unet_config.get("image_model") + context_dimension_value = unet_config.get("context_in_dim") + image_model = image_model_value if isinstance(image_model_value, str) else None + context_dimension = ( + context_dimension_value + if isinstance(context_dimension_value, int) + and not isinstance(context_dimension_value, bool) + else None + ) + return classify_flux_profile(image_model, context_dimension) diff --git a/simple_syrup/runtime/model_catalog.py b/simple_syrup/runtime/model_catalog.py index 5c79ad0..dfa5abf 100644 --- a/simple_syrup/runtime/model_catalog.py +++ b/simple_syrup/runtime/model_catalog.py @@ -31,20 +31,6 @@ class ModelArtifact: description: str -@dataclass(frozen=True) -class AutoModelArtifact: - """A trusted auto-resolved model artifact with a canonical destination.""" - - cache_id: str - filename: str - folder_name: str - canonical_subfolder: str - source_url: str - source_repo: str - description: str - sha256: str - - @dataclass(frozen=True) class ModelEntry: """A known model selection and its source metadata.""" @@ -399,36 +385,6 @@ WD14_TAGGER_ENTRIES: tuple[ModelEntry, ...] = ( _wd14_tagger_entry("wd-v1-4-vit-tagger"), ) -ANIMA_QWEN_TEXT_ENCODER = AutoModelArtifact( - cache_id="anima_qwen_text_encoder", - filename="qwen_3_06b_base.safetensors", - folder_name="text_encoders", - canonical_subfolder="qwen", - source_url=( - "https://huggingface.co/circlestone-labs/Anima/resolve/main/" - "split_files/text_encoders/qwen_3_06b_base.safetensors" - ), - source_repo="circlestone-labs/Anima", - description="Anima Qwen3 0.6B text encoder", - sha256="cd2a512003e2f9f3cd3c32a9c3573f820bb28c940f73c57b1ddaa983d9223eba", -) - -ANIMA_QWEN_VAE = AutoModelArtifact( - cache_id="anima_qwen_vae", - filename="qwen_image_vae.safetensors", - folder_name="vae", - canonical_subfolder="qwen", - source_url=( - "https://huggingface.co/circlestone-labs/Anima/resolve/main/" - "split_files/vae/qwen_image_vae.safetensors" - ), - source_repo="circlestone-labs/Anima", - description="Anima Qwen Image VAE", - sha256="a70580f0213e67967ee9c95f05bb400e8fb08307e017a924bf3441223e023d1f", -) - -ANIMA_AUTO_ARTIFACTS = (ANIMA_QWEN_TEXT_ENCODER, ANIMA_QWEN_VAE) - def sam_choices() -> list[str]: """Return deterministic SAM dropdown choices.""" diff --git a/simple_syrup/runtime/model_downloads.py b/simple_syrup/runtime/model_downloads.py index d41f3c0..172d40b 100644 --- a/simple_syrup/runtime/model_downloads.py +++ b/simple_syrup/runtime/model_downloads.py @@ -128,6 +128,22 @@ class ModelDownloader: self._validate_destination(destination, request.expected_folder) if destination.is_file(): + if request.expected_sha256 is not None: + actual_sha256 = sha256_file(destination) + if actual_sha256.lower() != request.expected_sha256.lower(): + LOGGER.error( + "existing model artifact checksum mismatch", + extra={ + "destination": str(destination), + "description": request.description, + }, + ) + raise ValueError( + "Existing model artifact checksum mismatch for " + f"'{destination}'. Expected {request.expected_sha256}, " + f"got {actual_sha256}. Remove or replace this file before " + "using automatic selection." + ) return DownloadResult( path=destination, bytes_downloaded=0, @@ -153,7 +169,7 @@ class ModelDownloader: description=request.description, ) if request.expected_sha256 is not None: - actual_sha256 = _sha256_file(temporary_path) + actual_sha256 = sha256_file(temporary_path) if actual_sha256.lower() != request.expected_sha256.lower(): raise ValueError( "Downloaded model artifact checksum mismatch for " @@ -231,7 +247,7 @@ def _content_length(response: BinaryIO) -> int | None: return None -def _sha256_file(path: Path) -> str: +def sha256_file(path: Path) -> str: """Return the SHA256 hex digest for a file.""" digest = hashlib.sha256() diff --git a/simple_syrup/runtime/text_encoder_loader.py b/simple_syrup/runtime/text_encoder_loader.py new file mode 100644 index 0000000..2fa1196 --- /dev/null +++ b/simple_syrup/runtime/text_encoder_loader.py @@ -0,0 +1,67 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Load FLUX text encoders through ComfyUI's native CLIP loader.""" + +from __future__ import annotations + +import importlib +from collections.abc import Sequence +from pathlib import Path +from types import ModuleType +from typing import Any + +import torch + +TEXT_ENCODER_DEVICES = ("default", "cpu") + + +class TextEncoderLoader: + """Adapt ComfyUI text-encoder loading behind a typed boundary.""" + + def __init__(self, folder_paths_module: ModuleType | None = None) -> None: + """Create a loader with injectable ComfyUI folder paths.""" + + self._folder_paths_module = folder_paths_module + + def load( + self, + paths: Sequence[Path], + clip_type_name: str, + device: str, + ) -> object: + """Load one or more encoder files for a named ComfyUI CLIP type.""" + + if device not in TEXT_ENCODER_DEVICES: + valid = ", ".join(TEXT_ENCODER_DEVICES) + raise ValueError(f"text_encoder_device must be one of: {valid}.") + if not paths: + raise ValueError("At least one text encoder path is required.") + + comfy_sd: Any = importlib.import_module("comfy.sd") + clip_type = getattr(comfy_sd.CLIPType, clip_type_name) + model_options: dict[str, object] = {} + if device == "cpu": + cpu_device = torch.device("cpu") + model_options = { + "load_device": cpu_device, + "offload_device": cpu_device, + } + return comfy_sd.load_clip( + ckpt_paths=[str(path) for path in paths], + embedding_directory=self._folder_paths().get_folder_paths("embeddings"), + clip_type=clip_type, + model_options=model_options, + ) + + def _folder_paths(self) -> ModuleType: + """Return the configured ComfyUI folder paths module.""" + + if self._folder_paths_module is not None: + return self._folder_paths_module + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + self._folder_paths_module = module + return module diff --git a/simple_syrup/services/flux2_loader_service.py b/simple_syrup/services/flux2_loader_service.py new file mode 100644 index 0000000..33c2c95 --- /dev/null +++ b/simple_syrup/services/flux2_loader_service.py @@ -0,0 +1,112 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Orchestrate FLUX.2 diffusion, profile-specific encoder, and VAE loading.""" + +from __future__ import annotations + +from types import ModuleType + +from ..domain.flux_profiles import FluxGeneration, FluxModelProfile +from ..runtime.auto_model_artifact import AutoModelArtifact +from ..runtime.diffusion_model_loader import DiffusionModelLoader +from ..runtime.flux_artifacts import FLUX2_TEXT_ENCODERS, FLUX2_VAE +from ..runtime.flux_model_inspector import FluxModelInspector +from ..runtime.model_downloads import ProgressReporter +from ..runtime.text_encoder_loader import TextEncoderLoader +from .flux_loader_boundaries import ( + DiffusionModelLoaderBoundary, + FluxLoaderComponentsBoundary, + FluxModelInspectorBoundary, + TextEncoderLoaderBoundary, +) +from .flux_loader_components import AUTO_CHOICE, FluxLoaderComponents + + +class Flux2LoaderService: + """Load FLUX.2 with its structurally selected encoder and common VAE.""" + + def __init__( + self, + diffusion_loader: DiffusionModelLoaderBoundary | None = None, + text_encoder_loader: TextEncoderLoaderBoundary | None = None, + model_inspector: FluxModelInspectorBoundary | None = None, + components: FluxLoaderComponentsBoundary | None = None, + folder_paths_module: ModuleType | None = None, + ) -> None: + """Create a FLUX.2 loader with injectable architecture boundaries.""" + + self._diffusion_loader = diffusion_loader or DiffusionModelLoader( + folder_paths_module + ) + self._text_encoder_loader = text_encoder_loader or TextEncoderLoader( + folder_paths_module + ) + self._model_inspector = model_inspector or FluxModelInspector() + self._components = components or FluxLoaderComponents( + folder_paths_module=folder_paths_module + ) + + def load_models( + self, + diffusion_model: str, + diffusion_weight_dtype: str, + text_encoder: str, + text_encoder_device: str, + vae: str, + progress: ProgressReporter | None = None, + ) -> tuple[object, object, object]: + """Return ComfyUI MODEL, CLIP, and VAE objects for FLUX.2.""" + + model = self._diffusion_loader.load( + diffusion_model, + diffusion_weight_dtype, + ) + profile = ( + self._require_flux2_profile(model) + if AUTO_CHOICE in (text_encoder, vae) + else None + ) + encoder_artifact = self._select_encoder_artifact(text_encoder, profile) + encoder_path = self._components.resolve_text_encoder( + text_encoder, + encoder_artifact, + progress, + ) + clip = self._text_encoder_loader.load( + (encoder_path,), + "FLUX2", + text_encoder_device, + ) + loaded_vae = self._components.load_vae(vae, FLUX2_VAE, progress) + return model, clip, loaded_vae + + def _require_flux2_profile(self, model: object) -> FluxModelProfile: + """Require structural FLUX.2 detection before automatic selection.""" + + profile = self._model_inspector.inspect(model) + if profile is not None and profile.generation is FluxGeneration.FLUX2: + return profile + raise ValueError( + "Automatic FLUX.2 components require a diffusion model ComfyUI " + "recognizes as FLUX.2. Select the text encoder and VAE manually " + "to try an unrecognized or non-FLUX model." + ) + + def _select_encoder_artifact( + self, + selection: str, + profile: FluxModelProfile | None, + ) -> AutoModelArtifact | None: + """Return the automatic profile artifact when auto is selected.""" + + if selection != AUTO_CHOICE: + return None + if profile is None or profile.flux2_text_encoder is None: + raise ValueError( + "ComfyUI recognized FLUX.2 but its text-conditioning dimension " + "does not match dev, Klein 4B, or Klein 9B/KV. Select the text " + "encoder manually." + ) + return FLUX2_TEXT_ENCODERS[profile.flux2_text_encoder] diff --git a/simple_syrup/services/flux_loader_boundaries.py b/simple_syrup/services/flux_loader_boundaries.py new file mode 100644 index 0000000..757b420 --- /dev/null +++ b/simple_syrup/services/flux_loader_boundaries.py @@ -0,0 +1,60 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Define collaboration boundaries shared by the FLUX loading use cases.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Protocol + +from ..domain.flux_profiles import FluxModelProfile +from ..runtime.auto_model_artifact import AutoModelArtifact +from ..runtime.model_downloads import ProgressReporter + + +class DiffusionModelLoaderBoundary(Protocol): + """Load a selected standalone diffusion model.""" + + def load(self, diffusion_model: str, weight_dtype: str) -> object: + """Load and return a ComfyUI model patcher.""" + + +class TextEncoderLoaderBoundary(Protocol): + """Load selected text encoders for a ComfyUI CLIP family.""" + + def load( + self, + paths: tuple[Path, ...], + clip_type_name: str, + device: str, + ) -> object: + """Load and return a ComfyUI CLIP object.""" + + +class FluxModelInspectorBoundary(Protocol): + """Classify a loaded diffusion model from ComfyUI metadata.""" + + def inspect(self, model: object) -> FluxModelProfile | None: + """Return the detected FLUX profile when available.""" + + +class FluxLoaderComponentsBoundary(Protocol): + """Resolve and load selectable FLUX support components.""" + + def resolve_text_encoder( + self, + selection: str, + automatic_artifact: AutoModelArtifact | None, + progress: ProgressReporter | None, + ) -> Path: + """Resolve an automatic or manual text encoder.""" + + def load_vae( + self, + selection: str, + automatic_artifact: AutoModelArtifact, + progress: ProgressReporter | None, + ) -> object: + """Load an automatic or manual VAE.""" diff --git a/simple_syrup/services/flux_loader_components.py b/simple_syrup/services/flux_loader_components.py new file mode 100644 index 0000000..4b530df --- /dev/null +++ b/simple_syrup/services/flux_loader_components.py @@ -0,0 +1,97 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Resolve automatic or manual component selections for FLUX loaders.""" + +from __future__ import annotations + +import importlib +from pathlib import Path +from types import ModuleType +from typing import Any, Protocol + +from ..runtime.auto_model_artifact import AutoModelArtifact +from ..runtime.auto_model_resolver import AutoModelResolution, AutoModelResolver +from ..runtime.model_downloads import ProgressReporter +from ..runtime.vae_loader import VaeLoaderService, load_vae_path + +AUTO_CHOICE = "auto" + + +class AutoModelResolverBoundary(Protocol): + """Resolve a trusted automatic artifact to a local path.""" + + def resolve( + self, + artifact: AutoModelArtifact, + progress: ProgressReporter | None = None, + ) -> AutoModelResolution: + """Resolve one automatic artifact.""" + + +class VaeLoaderBoundary(Protocol): + """Load a manually selected ComfyUI VAE choice.""" + + def load_vae(self, vae_name: str) -> object: + """Load one manual VAE selection.""" + + +class FluxLoaderComponents: + """Own component selection, automatic resolution, and VAE loading.""" + + def __init__( + self, + resolver: AutoModelResolverBoundary | None = None, + vae_loader: VaeLoaderBoundary | None = None, + folder_paths_module: ModuleType | None = None, + ) -> None: + """Create component orchestration with injectable external boundaries.""" + + self._folder_paths_module = folder_paths_module + self._resolver = resolver or AutoModelResolver( + folder_paths_module=folder_paths_module + ) + self._vae_loader = vae_loader or VaeLoaderService(folder_paths_module) + + def resolve_text_encoder( + self, + selection: str, + automatic_artifact: AutoModelArtifact | None, + progress: ProgressReporter | None, + ) -> Path: + """Resolve an automatic artifact or a manual text-encoder selection.""" + + if selection == AUTO_CHOICE: + if automatic_artifact is None: + raise ValueError("Automatic text encoder artifact was not resolved.") + return self._resolver.resolve(automatic_artifact, progress).path + path = self._folder_paths().get_full_path_or_raise( + "text_encoders", + selection, + ) + return Path(str(path)) + + def load_vae( + self, + selection: str, + automatic_artifact: AutoModelArtifact, + progress: ProgressReporter | None, + ) -> object: + """Resolve and load an automatic VAE or load a manual VAE selection.""" + + if selection == AUTO_CHOICE: + path = self._resolver.resolve(automatic_artifact, progress).path + return load_vae_path(path) + return self._vae_loader.load_vae(selection) + + def _folder_paths(self) -> ModuleType: + """Return the configured ComfyUI folder paths module.""" + + if self._folder_paths_module is not None: + return self._folder_paths_module + module: Any = importlib.import_module("folder_paths") + if not isinstance(module, ModuleType): + raise TypeError("folder_paths import did not return a module.") + self._folder_paths_module = module + return module diff --git a/simple_syrup/services/flux_loader_service.py b/simple_syrup/services/flux_loader_service.py new file mode 100644 index 0000000..60baff5 --- /dev/null +++ b/simple_syrup/services/flux_loader_service.py @@ -0,0 +1,97 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Orchestrate FLUX.1 diffusion, dual text-encoder, and VAE loading.""" + +from __future__ import annotations + +from types import ModuleType + +from ..domain.flux_profiles import FluxGeneration +from ..runtime.diffusion_model_loader import DiffusionModelLoader +from ..runtime.flux_artifacts import FLUX_CLIP_L, FLUX_T5_XXL, FLUX_VAE +from ..runtime.flux_model_inspector import FluxModelInspector +from ..runtime.model_downloads import ProgressReporter +from ..runtime.text_encoder_loader import TextEncoderLoader +from .flux_loader_boundaries import ( + DiffusionModelLoaderBoundary, + FluxLoaderComponentsBoundary, + FluxModelInspectorBoundary, + TextEncoderLoaderBoundary, +) +from .flux_loader_components import AUTO_CHOICE, FluxLoaderComponents + + +class FluxLoaderService: + """Load FLUX.1 with its required CLIP-L, T5-XXL, and VAE components.""" + + def __init__( + self, + diffusion_loader: DiffusionModelLoaderBoundary | None = None, + text_encoder_loader: TextEncoderLoaderBoundary | None = None, + model_inspector: FluxModelInspectorBoundary | None = None, + components: FluxLoaderComponentsBoundary | None = None, + folder_paths_module: ModuleType | None = None, + ) -> None: + """Create a FLUX.1 loader with injectable architecture boundaries.""" + + self._diffusion_loader = diffusion_loader or DiffusionModelLoader( + folder_paths_module + ) + self._text_encoder_loader = text_encoder_loader or TextEncoderLoader( + folder_paths_module + ) + self._model_inspector = model_inspector or FluxModelInspector() + self._components = components or FluxLoaderComponents( + folder_paths_module=folder_paths_module + ) + + def load_models( + self, + diffusion_model: str, + diffusion_weight_dtype: str, + clip_l: str, + t5_xxl: str, + text_encoder_device: str, + vae: str, + progress: ProgressReporter | None = None, + ) -> tuple[object, object, object]: + """Return ComfyUI MODEL, CLIP, and VAE objects for FLUX.1.""" + + model = self._diffusion_loader.load( + diffusion_model, + diffusion_weight_dtype, + ) + if AUTO_CHOICE in (clip_l, t5_xxl, vae): + self._require_flux_profile(model) + + clip_l_path = self._components.resolve_text_encoder( + clip_l, + FLUX_CLIP_L, + progress, + ) + t5_xxl_path = self._components.resolve_text_encoder( + t5_xxl, + FLUX_T5_XXL, + progress, + ) + clip = self._text_encoder_loader.load( + (clip_l_path, t5_xxl_path), + "FLUX", + text_encoder_device, + ) + loaded_vae = self._components.load_vae(vae, FLUX_VAE, progress) + return model, clip, loaded_vae + + def _require_flux_profile(self, model: object) -> None: + """Require structural FLUX.1 detection before automatic selection.""" + + profile = self._model_inspector.inspect(model) + if profile is not None and profile.generation is FluxGeneration.FLUX: + return + raise ValueError( + "Automatic FLUX.1 components require a diffusion model ComfyUI " + "recognizes as FLUX.1. Select the text encoders and VAE manually " + "to try an unrecognized or non-FLUX model." + ) diff --git a/tests/test_anima_loader.py b/tests/test_anima_loader.py index 23047aa..1d7ad09 100644 --- a/tests/test_anima_loader.py +++ b/tests/test_anima_loader.py @@ -6,21 +6,33 @@ from __future__ import annotations +import hashlib import sys +import urllib.request from dataclasses import dataclass, field from enum import Enum from pathlib import Path -from types import ModuleType +from types import ModuleType, TracebackType import pytest import torch +import simple_syrup.runtime.anima_loader as anima_loader_module from simple_syrup.runtime.anima_loader import ( AUTO_CHOICE, AnimaLoaderService, ) -from simple_syrup.runtime.auto_model_resolver import AutoModelResolution -from simple_syrup.runtime.model_catalog import AutoModelArtifact +from simple_syrup.runtime.auto_model_artifact import AutoModelArtifact +from simple_syrup.runtime.auto_model_cache import AutoModelCache +from simple_syrup.runtime.auto_model_resolver import ( + AutoModelResolution, + AutoModelResolver, +) +from simple_syrup.runtime.model_downloads import ( + ComfyProgressReporter, + ModelDownloader, + ProgressReporter, +) from simple_syrup.runtime.vae_loader import vae_choices @@ -31,6 +43,41 @@ class FakeComfyState: diffusion_calls: list[tuple[str, dict[str, object]]] = field(default_factory=list) clip_calls: list[dict[str, object]] = field(default_factory=list) vae_paths: list[str] = field(default_factory=list) + progress_totals: list[int] = field(default_factory=list) + progress_updates: list[list[tuple[int, int | None]]] = field(default_factory=list) + + +class FakeStreamingResponse: + """Stream fixed bytes through urllib's response protocol.""" + + def __init__(self, content: bytes) -> None: + """Create one response with a known content length.""" + + self._content = content + self._read = False + self.headers = {"Content-Length": str(len(content))} + + def __enter__(self) -> FakeStreamingResponse: + """Enter the response context.""" + + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Exit the response context.""" + + def read(self, size: int) -> bytes: + """Return the fixed payload once.""" + + del size + if self._read: + return b"" + self._read = True + return self._content class FakeFolderPaths(ModuleType): @@ -74,16 +121,17 @@ class FakeResolver: self.text_encoder_path = text_encoder_path self.vae_path = vae_path self.requests: list[str] = [] + self.progress_reporters: list[ProgressReporter | None] = [] def resolve( self, artifact: AutoModelArtifact, - progress: object | None = None, + progress: ProgressReporter | None = None, ) -> AutoModelResolution: """Record and resolve one artifact.""" - del progress self.requests.append(artifact.cache_id) + self.progress_reporters.append(progress) if artifact.folder_name == "text_encoders": return AutoModelResolution(self.text_encoder_path, "cached") return AutoModelResolution(self.vae_path, "cached") @@ -165,19 +213,115 @@ def test_loader_uses_auto_resolver_for_auto_choices( ) service = AnimaLoaderService(resolver=resolver, folder_paths_module=folder_paths) + progress = RecordingProgress() service.load_models( "anima.safetensors", "default", AUTO_CHOICE, "default", AUTO_CHOICE, + progress, ) assert resolver.requests == ["anima_qwen_text_encoder", "anima_qwen_vae"] + assert resolver.progress_reporters == [progress, progress] assert comfy_state.clip_calls[0]["ckpt_paths"] == [str(resolver.text_encoder_path)] assert comfy_state.vae_paths == [str(resolver.vae_path)] +def test_anima_auto_downloads_emit_comfy_node_progress_end_to_end( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Anima propagates real resolver downloads into ComfyUI progress bars.""" + + comfy_state = _install_fake_comfy(monkeypatch) + folder_paths = FakeFolderPaths(tmp_path / "models") + text_content = b"text" + vae_content = b"vae" + text_artifact = _small_artifact( + "anima_progress_text", + "text_encoders", + "progress_text.safetensors", + text_content, + ) + vae_artifact = _small_artifact( + "anima_progress_vae", + "vae", + "progress_vae.safetensors", + vae_content, + ) + monkeypatch.setattr( + anima_loader_module, + "ANIMA_QWEN_TEXT_ENCODER", + text_artifact, + ) + monkeypatch.setattr(anima_loader_module, "ANIMA_QWEN_VAE", vae_artifact) + + content_by_url = { + text_artifact.source_url: text_content, + vae_artifact.source_url: vae_content, + } + + def open_artifact(url: str, timeout: int) -> FakeStreamingResponse: + """Return the tiny payload associated with a trusted test URL.""" + + del timeout + return FakeStreamingResponse(content_by_url[str(url)]) + + monkeypatch.setattr( + urllib.request, + "urlopen", + open_artifact, + ) + resolver = AutoModelResolver( + cache=AutoModelCache(folder_paths), + downloader=ModelDownloader(), + folder_paths_module=folder_paths, + ) + service = AnimaLoaderService( + resolver=resolver, + folder_paths_module=folder_paths, + ) + + service.load_models( + "anima.safetensors", + "default", + AUTO_CHOICE, + "default", + AUTO_CHOICE, + ComfyProgressReporter(), + ) + + assert comfy_state.progress_totals == [len(text_content), len(vae_content)] + assert comfy_state.progress_updates == [ + [ + (0, len(text_content)), + (len(text_content), len(text_content)), + (len(text_content), len(text_content)), + ], + [ + (0, len(vae_content)), + (len(vae_content), len(vae_content)), + (len(vae_content), len(vae_content)), + ], + ] + + +@dataclass +class RecordingProgress: + """Identity-bearing progress reporter used to verify service propagation.""" + + def start(self, label: str, total: int | None) -> None: + """Accept a progress start.""" + + def advance(self, current: int, total: int | None) -> None: + """Accept a progress update.""" + + def finish(self) -> None: + """Accept progress completion.""" + + def test_loader_returns_model_clip_and_vae( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -297,6 +441,22 @@ def _install_fake_comfy(monkeypatch: pytest.MonkeyPatch) -> FakeComfyState: comfy_sd.load_clip = load_clip # type: ignore[attr-defined] comfy_sd.VAE = FakeVAE # type: ignore[attr-defined] comfy_utils.load_torch_file = load_torch_file # type: ignore[attr-defined] + + class FakeProgressBar: + """Record one ComfyUI node progress bar.""" + + def __init__(self, total: int) -> None: + """Create one progress update series.""" + + state.progress_totals.append(total) + state.progress_updates.append([]) + + def update_absolute(self, value: int, total: int | None = None) -> None: + """Record one absolute progress update.""" + + state.progress_updates[-1].append((value, total)) + + comfy_utils.ProgressBar = FakeProgressBar # type: ignore[attr-defined] comfy_module.sd = comfy_sd # type: ignore[attr-defined] comfy_module.utils = comfy_utils # type: ignore[attr-defined] @@ -304,3 +464,23 @@ def _install_fake_comfy(monkeypatch: pytest.MonkeyPatch) -> FakeComfyState: monkeypatch.setitem(sys.modules, "comfy.sd", comfy_sd) monkeypatch.setitem(sys.modules, "comfy.utils", comfy_utils) return state + + +def _small_artifact( + cache_id: str, + folder_name: str, + filename: str, + content: bytes, +) -> AutoModelArtifact: + """Create a tiny checksum-pinned artifact for integration testing.""" + + return AutoModelArtifact( + cache_id=cache_id, + filename=filename, + folder_name=folder_name, + canonical_subfolder="progress_test", + source_url=f"https://example.invalid/{filename}", + source_repo="example/progress", + description=f"progress test {filename}", + sha256=hashlib.sha256(content).hexdigest(), + ) diff --git a/tests/test_auto_model_cache.py b/tests/test_auto_model_cache.py index 5f39291..2e4ef1e 100644 --- a/tests/test_auto_model_cache.py +++ b/tests/test_auto_model_cache.py @@ -62,11 +62,17 @@ def test_cache_saves_and_loads_entry(tmp_path: Path) -> None: / "qwen_3_06b_base.safetensors", source="downloaded", sha256="abc123", + file_size=123, + modified_time_ns=456, ) cache.save_entry("anima_qwen_text_encoder", entry) assert cache.load() == {"anima_qwen_text_encoder": entry} + payload = json.loads(cache.cache_path().read_text(encoding="utf-8")) + saved_entry = payload["entries"]["anima_qwen_text_encoder"] + assert saved_entry["file_size"] == 123 + assert saved_entry["modified_time_ns"] == 456 def test_cache_preserves_unrelated_entries(tmp_path: Path) -> None: diff --git a/tests/test_auto_model_resolver.py b/tests/test_auto_model_resolver.py index 8df5907..2e3e72e 100644 --- a/tests/test_auto_model_resolver.py +++ b/tests/test_auto_model_resolver.py @@ -6,10 +6,12 @@ from __future__ import annotations +import hashlib from pathlib import Path import pytest +from simple_syrup.runtime.auto_model_artifact import AutoModelArtifact from simple_syrup.runtime.auto_model_cache import AutoModelCache, AutoModelCacheEntry from simple_syrup.runtime.auto_model_resolver import ( AutoModelResolver, @@ -17,7 +19,6 @@ from simple_syrup.runtime.auto_model_resolver import ( find_model_by_basename, relative_model_name, ) -from simple_syrup.runtime.model_catalog import AutoModelArtifact from simple_syrup.runtime.model_downloads import DownloadRequest, DownloadResult from test_helpers import FakeFolderPaths @@ -106,6 +107,57 @@ def test_resolver_repairs_stale_cache_with_recursive_search(tmp_path: Path) -> N assert cache.load()[artifact.cache_id].path == found_path +def test_resolver_rejects_legacy_cached_file_with_wrong_checksum( + tmp_path: Path, +) -> None: + """A metadata-free legacy cache entry is hashed before it is trusted.""" + + fake = FakeFolderPaths(tmp_path / "models") + artifact = _artifact("text_encoders", "model.safetensors") + cached_path = tmp_path / "models" / "text_encoders" / "old" / artifact.filename + cached_path.parent.mkdir(parents=True) + cached_path.write_bytes(b"wrong") + cache = AutoModelCache(fake) + cache.save_entry( + artifact.cache_id, + AutoModelCacheEntry( + folder_name=artifact.folder_name, + filename=artifact.filename, + path=cached_path, + source="found", + sha256=artifact.sha256, + ), + ) + downloader = RecordingDownloader() + + resolved = AutoModelResolver(cache, downloader, fake).resolve(artifact) + + assert resolved.source == "downloaded" + assert resolved.path != cached_path + assert len(downloader.requests) == 1 + + +def test_resolver_ignores_same_named_file_with_wrong_checksum(tmp_path: Path) -> None: + """Recursive discovery accepts only the checksum-pinned artifact bytes.""" + + fake = FakeFolderPaths(tmp_path / "models") + artifact = _artifact("text_encoders", "model.safetensors") + wrong_path = tmp_path / "models" / "text_encoders" / "unrelated" / artifact.filename + wrong_path.parent.mkdir(parents=True) + wrong_path.write_bytes(b"wrong") + downloader = RecordingDownloader() + + resolved = AutoModelResolver( + AutoModelCache(fake), + downloader, + fake, + ).resolve(artifact) + + assert resolved.source == "downloaded" + assert resolved.path != wrong_path + assert len(downloader.requests) == 1 + + def test_find_model_by_basename_respects_folder_priority(tmp_path: Path) -> None: """Recursive search prefers earlier ComfyUI model roots.""" @@ -221,5 +273,5 @@ def _artifact(folder_name: str, filename: str) -> AutoModelArtifact: source_url=f"https://example.invalid/{filename}", source_repo="example/model", description=f"test {filename}", - sha256="abc123", + sha256=hashlib.sha256(b"model").hexdigest(), ) diff --git a/tests/test_flux_artifacts.py b/tests/test_flux_artifacts.py new file mode 100644 index 0000000..98a8eb7 --- /dev/null +++ b/tests/test_flux_artifacts.py @@ -0,0 +1,59 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for trusted FLUX automatic artifact metadata.""" + +from __future__ import annotations + +from simple_syrup.domain.flux_profiles import Flux2TextEncoderProfile +from simple_syrup.runtime.flux_artifacts import ( + FLUX2_DEV_TEXT_ENCODER, + FLUX2_KLEIN_4B_TEXT_ENCODER, + FLUX2_KLEIN_9B_TEXT_ENCODER, + FLUX2_TEXT_ENCODERS, + FLUX2_VAE, + FLUX_CLIP_L, + FLUX_T5_XXL, + FLUX_VAE, +) + + +def test_flux_artifacts_are_checksum_pinned_to_immutable_revisions() -> None: + """Every automatic file has a SHA-256 and commit-pinned Hugging Face URL.""" + + artifacts = ( + FLUX_CLIP_L, + FLUX_T5_XXL, + FLUX_VAE, + FLUX2_DEV_TEXT_ENCODER, + FLUX2_KLEIN_4B_TEXT_ENCODER, + FLUX2_KLEIN_9B_TEXT_ENCODER, + FLUX2_VAE, + ) + + for artifact in artifacts: + assert artifact.source_url.startswith("https://huggingface.co/") + assert "/resolve/main/" not in artifact.source_url + assert len(artifact.sha256) == 64 + assert set(artifact.sha256) <= set("0123456789abcdef") + + +def test_flux2_encoder_profiles_map_to_the_expected_families() -> None: + """Dev and both Klein architecture widths select distinct encoders.""" + + assert FLUX2_TEXT_ENCODERS == { + Flux2TextEncoderProfile.DEV: FLUX2_DEV_TEXT_ENCODER, + Flux2TextEncoderProfile.KLEIN_4B: FLUX2_KLEIN_4B_TEXT_ENCODER, + Flux2TextEncoderProfile.KLEIN_9B: FLUX2_KLEIN_9B_TEXT_ENCODER, + } + assert FLUX2_DEV_TEXT_ENCODER.filename.startswith("mistral_3_small") + assert FLUX2_KLEIN_4B_TEXT_ENCODER.filename == "qwen_3_4b.safetensors" + assert FLUX2_KLEIN_9B_TEXT_ENCODER.filename == ("qwen_3_8b_fp8mixed.safetensors") + + +def test_flux_generations_use_separate_vae_artifacts() -> None: + """FLUX.1 and FLUX.2 auto select their generation-specific VAE files.""" + + assert FLUX_VAE.filename == "ae.safetensors" + assert FLUX2_VAE.filename == "flux2-vae.safetensors" diff --git a/tests/test_flux_loader_services.py b/tests/test_flux_loader_services.py new file mode 100644 index 0000000..11a4b40 --- /dev/null +++ b/tests/test_flux_loader_services.py @@ -0,0 +1,336 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for separate FLUX.1 and FLUX.2 loading orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + +from simple_syrup.domain.flux_profiles import ( + Flux2TextEncoderProfile, + FluxGeneration, + FluxModelProfile, +) +from simple_syrup.runtime.auto_model_artifact import AutoModelArtifact +from simple_syrup.runtime.flux_artifacts import ( + FLUX2_DEV_TEXT_ENCODER, + FLUX2_KLEIN_4B_TEXT_ENCODER, + FLUX2_KLEIN_9B_TEXT_ENCODER, + FLUX2_VAE, + FLUX_CLIP_L, + FLUX_T5_XXL, + FLUX_VAE, +) +from simple_syrup.runtime.model_downloads import ProgressReporter +from simple_syrup.services.flux2_loader_service import Flux2LoaderService +from simple_syrup.services.flux_loader_components import AUTO_CHOICE +from simple_syrup.services.flux_loader_service import FluxLoaderService + + +class RecordingDiffusionLoader: + """Record diffusion selection and return a fixed model object.""" + + def __init__(self) -> None: + """Create an empty call log.""" + + self.calls: list[tuple[str, str]] = [] + self.model = object() + + def load(self, diffusion_model: str, weight_dtype: str) -> object: + """Record the requested standalone model.""" + + self.calls.append((diffusion_model, weight_dtype)) + return self.model + + +class RecordingInspector: + """Return a configured model profile and record inspection calls.""" + + def __init__(self, profile: FluxModelProfile | None) -> None: + """Configure the profile returned by inspection.""" + + self.profile = profile + self.models: list[object] = [] + + def inspect(self, model: object) -> FluxModelProfile | None: + """Record and return the configured profile.""" + + self.models.append(model) + return self.profile + + +@dataclass +class RecordingComponents: + """Record automatic/manual component orchestration requests.""" + + encoder_requests: list[ + tuple[str, AutoModelArtifact | None, ProgressReporter | None] + ] = field(default_factory=list) + vae_requests: list[tuple[str, AutoModelArtifact, ProgressReporter | None]] = field( + default_factory=list + ) + + def resolve_text_encoder( + self, + selection: str, + automatic_artifact: AutoModelArtifact | None, + progress: ProgressReporter | None, + ) -> Path: + """Record and return a deterministic encoder path.""" + + self.encoder_requests.append((selection, automatic_artifact, progress)) + filename = ( + automatic_artifact.filename + if selection == AUTO_CHOICE and automatic_artifact is not None + else selection + ) + return Path("C:/models/text_encoders") / filename + + def load_vae( + self, + selection: str, + automatic_artifact: AutoModelArtifact, + progress: ProgressReporter | None, + ) -> object: + """Record and return a deterministic VAE object.""" + + self.vae_requests.append((selection, automatic_artifact, progress)) + return "vae" + + +@dataclass +class RecordingTextEncoderLoader: + """Record text encoder paths, Comfy CLIP type, and device.""" + + calls: list[tuple[tuple[Path, ...], str, str]] = field(default_factory=list) + + def load( + self, + paths: tuple[Path, ...], + clip_type_name: str, + device: str, + ) -> object: + """Record and return a fixed CLIP object.""" + + self.calls.append((paths, clip_type_name, device)) + return "clip" + + +class IdentityProgress: + """No-op progress reporter whose identity is asserted by tests.""" + + def start(self, label: str, total: int | None) -> None: + """Accept a progress start.""" + + def advance(self, current: int, total: int | None) -> None: + """Accept a progress update.""" + + def finish(self) -> None: + """Accept progress completion.""" + + +def test_flux1_auto_loads_dual_encoders_and_generation_vae() -> None: + """FLUX.1 auto resolves CLIP-L, T5-XXL, and ae through one progress path.""" + + diffusion = RecordingDiffusionLoader() + inspector = RecordingInspector(FluxModelProfile(FluxGeneration.FLUX)) + components = RecordingComponents() + text_loader = RecordingTextEncoderLoader() + progress = IdentityProgress() + service = FluxLoaderService(diffusion, text_loader, inspector, components) + + result = service.load_models( + "renamed-civit-finetune.safetensors", + "fp8_e4m3fn_fast", + AUTO_CHOICE, + AUTO_CHOICE, + "cpu", + AUTO_CHOICE, + progress, + ) + + assert result == (diffusion.model, "clip", "vae") + assert components.encoder_requests == [ + (AUTO_CHOICE, FLUX_CLIP_L, progress), + (AUTO_CHOICE, FLUX_T5_XXL, progress), + ] + assert components.vae_requests == [(AUTO_CHOICE, FLUX_VAE, progress)] + assert text_loader.calls[0][1:] == ("FLUX", "cpu") + + +def test_flux1_auto_rejects_unrecognized_model_with_manual_guidance() -> None: + """Unresolved FLUX.1 auto policy fails before any support artifact resolution.""" + + components = RecordingComponents() + service = FluxLoaderService( + RecordingDiffusionLoader(), + RecordingTextEncoderLoader(), + RecordingInspector(None), + components, + ) + + with pytest.raises(ValueError, match="Select the text encoders and VAE manually"): + service.load_models( + "unknown.safetensors", + "default", + AUTO_CHOICE, + AUTO_CHOICE, + "default", + AUTO_CHOICE, + ) + + assert components.encoder_requests == [] + + +def test_flux1_fully_manual_path_does_not_gatekeep_model_family() -> None: + """Manual selections proceed without architecture detection.""" + + inspector = RecordingInspector(None) + components = RecordingComponents() + text_loader = RecordingTextEncoderLoader() + service = FluxLoaderService( + RecordingDiffusionLoader(), + text_loader, + inspector, + components, + ) + + result = service.load_models( + "possibly-not-flux.safetensors", + "default", + "manual_clip.safetensors", + "manual_t5.safetensors", + "default", + "manual_vae.safetensors", + ) + + assert result[1:] == ("clip", "vae") + assert inspector.models == [] + assert text_loader.calls[0][1] == "FLUX" + + +@pytest.mark.parametrize( + ("encoder_profile", "expected_artifact"), + ( + (Flux2TextEncoderProfile.DEV, FLUX2_DEV_TEXT_ENCODER), + (Flux2TextEncoderProfile.KLEIN_4B, FLUX2_KLEIN_4B_TEXT_ENCODER), + (Flux2TextEncoderProfile.KLEIN_9B, FLUX2_KLEIN_9B_TEXT_ENCODER), + ), +) +def test_flux2_auto_selects_encoder_for_loaded_architecture( + encoder_profile: Flux2TextEncoderProfile, + expected_artifact: AutoModelArtifact, +) -> None: + """FLUX.2 auto supports dev, Klein 4B, and Klein 9B/KV profiles.""" + + components = RecordingComponents() + text_loader = RecordingTextEncoderLoader() + progress = IdentityProgress() + service = Flux2LoaderService( + RecordingDiffusionLoader(), + text_loader, + RecordingInspector(FluxModelProfile(FluxGeneration.FLUX2, encoder_profile)), + components, + ) + + result = service.load_models( + "arbitrarily-renamed-finetune.safetensors", + "default", + AUTO_CHOICE, + "default", + AUTO_CHOICE, + progress, + ) + + assert result[1:] == ("clip", "vae") + assert components.encoder_requests == [(AUTO_CHOICE, expected_artifact, progress)] + assert components.vae_requests == [(AUTO_CHOICE, FLUX2_VAE, progress)] + assert text_loader.calls[0][1] == "FLUX2" + + +def test_flux2_unknown_width_requires_manual_text_encoder_only() -> None: + """A recognized future FLUX.2 model can still auto-load its common VAE.""" + + profile = FluxModelProfile(FluxGeneration.FLUX2) + service = Flux2LoaderService( + RecordingDiffusionLoader(), + RecordingTextEncoderLoader(), + RecordingInspector(profile), + RecordingComponents(), + ) + + with pytest.raises(ValueError, match="Select the text encoder manually"): + service.load_models( + "future-flux2.safetensors", + "default", + AUTO_CHOICE, + "default", + AUTO_CHOICE, + ) + + components = RecordingComponents() + manual_service = Flux2LoaderService( + RecordingDiffusionLoader(), + RecordingTextEncoderLoader(), + RecordingInspector(profile), + components, + ) + manual_service.load_models( + "future-flux2.safetensors", + "default", + "manual_encoder.safetensors", + "default", + AUTO_CHOICE, + ) + + assert components.encoder_requests[0][1] is None + assert components.vae_requests[0][1] is FLUX2_VAE + + +def test_flux2_fully_manual_path_does_not_gatekeep_model_family() -> None: + """Manual FLUX.2 selections proceed even when Comfy cannot classify the model.""" + + inspector = RecordingInspector(None) + text_loader = RecordingTextEncoderLoader() + service = Flux2LoaderService( + RecordingDiffusionLoader(), + text_loader, + inspector, + RecordingComponents(), + ) + + service.load_models( + "non-flux-or-unknown.safetensors", + "default", + "manual_encoder.safetensors", + "default", + "manual_vae.safetensors", + ) + + assert inspector.models == [] + assert text_loader.calls[0][1] == "FLUX2" + + +def test_flux2_auto_rejects_a_detected_flux1_model() -> None: + """Cross-generation automatic selection fails with a manual escape hatch.""" + + service = Flux2LoaderService( + RecordingDiffusionLoader(), + RecordingTextEncoderLoader(), + RecordingInspector(FluxModelProfile(FluxGeneration.FLUX)), + RecordingComponents(), + ) + + with pytest.raises(ValueError, match="recognizes as FLUX.2"): + service.load_models( + "flux1.safetensors", + "default", + AUTO_CHOICE, + "default", + AUTO_CHOICE, + ) diff --git a/tests/test_flux_model_inspector.py b/tests/test_flux_model_inspector.py new file mode 100644 index 0000000..2799e7c --- /dev/null +++ b/tests/test_flux_model_inspector.py @@ -0,0 +1,61 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for loaded ComfyUI FLUX model metadata inspection.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from simple_syrup.domain.flux_profiles import ( + Flux2TextEncoderProfile, + FluxGeneration, +) +from simple_syrup.runtime.flux_model_inspector import FluxModelInspector + + +@dataclass +class FakeModelConfig: + """Minimal ComfyUI model-config double.""" + + unet_config: object + + +class FakeModelPatcher: + """Minimal ComfyUI model-patcher double.""" + + def __init__(self, unet_config: object) -> None: + """Store tensor-derived configuration independently of any filename.""" + + self.model_config = FakeModelConfig(unet_config) + + def get_model_object(self, name: str) -> object: + """Return the requested model configuration.""" + + assert name == "model_config" + return self.model_config + + +def test_inspector_detects_renamed_klein_finetune_from_loaded_config() -> None: + """Architecture detection depends on loaded dimensions, not model filenames.""" + + model = FakeModelPatcher({"image_model": "flux2", "context_in_dim": 12_288}) + + profile = FluxModelInspector().inspect(model) + + assert profile is not None + assert profile.generation is FluxGeneration.FLUX2 + assert profile.flux2_text_encoder is Flux2TextEncoderProfile.KLEIN_9B + + +def test_inspector_returns_none_without_model_patcher_metadata() -> None: + """Manual loader paths can proceed when a host object cannot be inspected.""" + + assert FluxModelInspector().inspect(object()) is None + + +def test_inspector_returns_none_for_malformed_unet_config() -> None: + """Malformed dynamic host metadata is narrowed conservatively.""" + + assert FluxModelInspector().inspect(FakeModelPatcher("not a mapping")) is None diff --git a/tests/test_flux_profiles.py b/tests/test_flux_profiles.py new file mode 100644 index 0000000..4a7e53c --- /dev/null +++ b/tests/test_flux_profiles.py @@ -0,0 +1,62 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for structural FLUX generation and encoder-profile classification.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.flux_profiles import ( + Flux2TextEncoderProfile, + FluxGeneration, + classify_flux_profile, +) + + +def test_classifies_flux1_without_filename_or_parameter_count() -> None: + """ComfyUI's FLUX image-model marker is sufficient for FLUX.1.""" + + profile = classify_flux_profile("flux", 4_096) + + assert profile is not None + assert profile.generation is FluxGeneration.FLUX + assert profile.flux2_text_encoder is None + + +@pytest.mark.parametrize( + ("context_dimension", "expected"), + ( + (15_360, Flux2TextEncoderProfile.DEV), + (7_680, Flux2TextEncoderProfile.KLEIN_4B), + (12_288, Flux2TextEncoderProfile.KLEIN_9B), + ), +) +def test_classifies_flux2_encoder_from_conditioning_dimension( + context_dimension: int, + expected: Flux2TextEncoderProfile, +) -> None: + """FLUX.2 dev and Klein variants map from tensor-derived input width.""" + + profile = classify_flux_profile("flux2", context_dimension) + + assert profile is not None + assert profile.generation is FluxGeneration.FLUX2 + assert profile.flux2_text_encoder is expected + + +def test_preserves_flux2_generation_for_unknown_conditioning_dimension() -> None: + """Unknown future FLUX.2 widths remain FLUX.2 with unresolved encoder policy.""" + + profile = classify_flux_profile("flux2", 99_999) + + assert profile is not None + assert profile.generation is FluxGeneration.FLUX2 + assert profile.flux2_text_encoder is None + + +def test_rejects_non_flux_image_model_marker() -> None: + """Unrelated ComfyUI model families do not receive a FLUX profile.""" + + assert classify_flux_profile("sd3", 4_096) is None diff --git a/tests/test_flux_runtime_loaders.py b/tests/test_flux_runtime_loaders.py new file mode 100644 index 0000000..ceceef6 --- /dev/null +++ b/tests/test_flux_runtime_loaders.py @@ -0,0 +1,293 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for ComfyUI runtime adapters used by the FLUX loader services.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from types import ModuleType + +import pytest +import torch + +import simple_syrup.services.flux_loader_components as components_module +from simple_syrup.runtime.auto_model_artifact import AutoModelArtifact +from simple_syrup.runtime.auto_model_resolver import AutoModelResolution +from simple_syrup.runtime.diffusion_model_loader import DiffusionModelLoader +from simple_syrup.runtime.model_downloads import ProgressReporter +from simple_syrup.runtime.text_encoder_loader import TextEncoderLoader +from simple_syrup.services.flux_loader_components import ( + AUTO_CHOICE, + FluxLoaderComponents, +) + + +class FakeFolderPaths(ModuleType): + """Resolve manual models to deterministic absolute test paths.""" + + def __init__(self, root: Path) -> None: + """Create folder paths rooted in the test directory.""" + + super().__init__("folder_paths") + self.root = root + + def get_full_path_or_raise(self, folder_name: str, filename: str) -> str: + """Return an absolute path for a named ComfyUI model selection.""" + + return str(self.root / folder_name / filename) + + def get_folder_paths(self, folder_name: str) -> list[str]: + """Return registered paths for embeddings and model folders.""" + + return [str(self.root / folder_name)] + + +@dataclass +class FakeComfyState: + """Record calls made through the ComfyUI loading adapters.""" + + diffusion_calls: list[tuple[str, dict[str, object]]] = field(default_factory=list) + clip_calls: list[dict[str, object]] = field(default_factory=list) + + +class FakeCLIPType(Enum): + """Represent the ComfyUI CLIP families used by FLUX loaders.""" + + FLUX = 1 + FLUX2 = 2 + + +class IdentityProgress: + """No-op progress reporter used to verify identity propagation.""" + + def start(self, label: str, total: int | None) -> None: + """Accept a progress start.""" + + def advance(self, current: int, total: int | None) -> None: + """Accept a progress update.""" + + def finish(self) -> None: + """Accept progress completion.""" + + +class RecordingResolver: + """Resolve trusted artifacts to deterministic local files.""" + + def __init__(self, root: Path) -> None: + """Create an empty automatic-resolution log.""" + + self.root = root + self.calls: list[tuple[AutoModelArtifact, ProgressReporter | None]] = [] + + def resolve( + self, + artifact: AutoModelArtifact, + progress: ProgressReporter | None = None, + ) -> AutoModelResolution: + """Record and return one automatic artifact path.""" + + self.calls.append((artifact, progress)) + return AutoModelResolution(self.root / artifact.filename, "cached") + + +class RecordingVaeLoader: + """Record manual VAE choices.""" + + def __init__(self) -> None: + """Create an empty manual VAE log.""" + + self.calls: list[str] = [] + + def load_vae(self, vae_name: str) -> object: + """Record and return a manual VAE object.""" + + self.calls.append(vae_name) + return "manual vae" + + +def test_diffusion_adapter_matches_comfy_unet_dtype_policy( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Standalone diffusion loading forwards ComfyUI's fast FP8 options.""" + + state = _install_fake_comfy(monkeypatch) + loader = DiffusionModelLoader(FakeFolderPaths(tmp_path)) + + model = loader.load("flux.safetensors", "fp8_e4m3fn_fast") + + assert model == "model" + assert state.diffusion_calls == [ + ( + str(tmp_path / "diffusion_models" / "flux.safetensors"), + {"dtype": torch.float8_e4m3fn, "fp8_optimizations": True}, + ) + ] + + +def test_diffusion_adapter_rejects_unknown_dtype(tmp_path: Path) -> None: + """Invalid workflow-facing dtype values fail before ComfyUI loading.""" + + with pytest.raises(ValueError, match="diffusion_weight_dtype"): + DiffusionModelLoader(FakeFolderPaths(tmp_path)).load( + "flux.safetensors", + "invalid", + ) + + +def test_text_encoder_adapter_loads_dual_flux_encoders_on_cpu( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Dual FLUX encoders use CLIPType.FLUX and explicit CPU placement.""" + + state = _install_fake_comfy(monkeypatch) + paths = (tmp_path / "clip.safetensors", tmp_path / "t5.safetensors") + + clip = TextEncoderLoader(FakeFolderPaths(tmp_path)).load(paths, "FLUX", "cpu") + + assert clip == "clip" + assert state.clip_calls[0]["paths"] == [str(path) for path in paths] + assert state.clip_calls[0]["clip_type"] is FakeCLIPType.FLUX + assert state.clip_calls[0]["model_options"] == { + "load_device": torch.device("cpu"), + "offload_device": torch.device("cpu"), + } + + +def test_text_encoder_adapter_rejects_unknown_device(tmp_path: Path) -> None: + """Invalid encoder device values fail before ComfyUI loading.""" + + with pytest.raises(ValueError, match="text_encoder_device"): + TextEncoderLoader(FakeFolderPaths(tmp_path)).load( + (tmp_path / "encoder.safetensors",), + "FLUX2", + "cuda:7", + ) + + +def test_component_orchestrator_preserves_auto_progress_and_manual_escape_hatches( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto resolution receives progress while manual selections remain direct.""" + + artifact = _artifact() + resolver = RecordingResolver(tmp_path / "resolved") + vae_loader = RecordingVaeLoader() + progress = IdentityProgress() + loaded_auto_paths: list[Path] = [] + + def load_automatic_vae(path: Path) -> object: + """Record and return an automatically resolved VAE path.""" + + loaded_auto_paths.append(path) + return "auto vae" + + monkeypatch.setattr( + components_module, + "load_vae_path", + load_automatic_vae, + ) + components = FluxLoaderComponents( + resolver, + vae_loader, + FakeFolderPaths(tmp_path), + ) + + automatic_path = components.resolve_text_encoder( + AUTO_CHOICE, + artifact, + progress, + ) + manual_path = components.resolve_text_encoder( + "manual.safetensors", + None, + progress, + ) + automatic_vae = components.load_vae(AUTO_CHOICE, artifact, progress) + manual_vae = components.load_vae("manual_vae.safetensors", artifact, progress) + + assert automatic_path == tmp_path / "resolved" / artifact.filename + assert manual_path == tmp_path / "text_encoders" / "manual.safetensors" + assert resolver.calls == [(artifact, progress), (artifact, progress)] + assert loaded_auto_paths == [tmp_path / "resolved" / artifact.filename] + assert automatic_vae == "auto vae" + assert manual_vae == "manual vae" + assert vae_loader.calls == ["manual_vae.safetensors"] + + +def test_component_orchestrator_rejects_unresolved_auto_encoder(tmp_path: Path) -> None: + """An automatic encoder request cannot silently fall through to a manual path.""" + + components = FluxLoaderComponents( + RecordingResolver(tmp_path), + RecordingVaeLoader(), + FakeFolderPaths(tmp_path), + ) + + with pytest.raises(ValueError, match="artifact was not resolved"): + components.resolve_text_encoder(AUTO_CHOICE, None, None) + + +def _install_fake_comfy(monkeypatch: pytest.MonkeyPatch) -> FakeComfyState: + """Install the ComfyUI APIs used by both runtime adapters.""" + + state = FakeComfyState() + comfy_module = ModuleType("comfy") + comfy_sd = ModuleType("comfy.sd") + comfy_sd.CLIPType = FakeCLIPType # type: ignore[attr-defined] + + def load_diffusion_model( + path: str, + model_options: dict[str, object], + ) -> object: + """Record standalone diffusion loading.""" + + state.diffusion_calls.append((path, model_options)) + return "model" + + def load_clip( + ckpt_paths: list[str], + embedding_directory: list[str], + clip_type: FakeCLIPType, + model_options: dict[str, object], + ) -> object: + """Record text-encoder loading.""" + + state.clip_calls.append( + { + "paths": ckpt_paths, + "embedding_directory": embedding_directory, + "clip_type": clip_type, + "model_options": model_options, + } + ) + return "clip" + + comfy_sd.load_diffusion_model = load_diffusion_model # type: ignore[attr-defined] + comfy_sd.load_clip = load_clip # type: ignore[attr-defined] + comfy_module.sd = comfy_sd # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "comfy", comfy_module) + monkeypatch.setitem(sys.modules, "comfy.sd", comfy_sd) + return state + + +def _artifact() -> AutoModelArtifact: + """Return a small trusted artifact for component orchestration tests.""" + + return AutoModelArtifact( + cache_id="test_encoder", + filename="auto.safetensors", + folder_name="text_encoders", + canonical_subfolder="flux", + source_url="https://example.invalid/auto.safetensors", + source_repo="example/auto", + description="test encoder", + sha256="0" * 64, + ) diff --git a/tests/test_model_catalog.py b/tests/test_model_catalog.py index 2358550..7ffb9a6 100644 --- a/tests/test_model_catalog.py +++ b/tests/test_model_catalog.py @@ -8,9 +8,11 @@ from __future__ import annotations import pytest -from simple_syrup.runtime.model_catalog import ( +from simple_syrup.runtime.anima_artifacts import ( ANIMA_QWEN_TEXT_ENCODER, ANIMA_QWEN_VAE, +) +from simple_syrup.runtime.model_catalog import ( BERT_ENTRY, GROUNDING_DINO_ENTRIES, SAM_ENTRIES, diff --git a/tests/test_model_downloads.py b/tests/test_model_downloads.py index 5d5651f..ee196ee 100644 --- a/tests/test_model_downloads.py +++ b/tests/test_model_downloads.py @@ -7,14 +7,19 @@ from __future__ import annotations import hashlib +import sys import urllib.request from dataclasses import dataclass, field from pathlib import Path -from types import TracebackType +from types import ModuleType, TracebackType import pytest -from simple_syrup.runtime.model_downloads import DownloadRequest, ModelDownloader +from simple_syrup.runtime.model_downloads import ( + ComfyProgressReporter, + DownloadRequest, + ModelDownloader, +) @dataclass @@ -73,6 +78,41 @@ class FakeResponse: return self._chunks.pop(0) +def test_comfy_progress_reporter_updates_the_active_node_progress( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Comfy progress reporting creates and completes a host progress bar.""" + + updates: list[tuple[int, int | None]] = [] + + class FakeProgressBar: + """Record ComfyUI absolute progress updates.""" + + def __init__(self, total: int) -> None: + """Record the total selected for the progress bar.""" + + updates.append((-1, total)) + + def update_absolute(self, value: int, total: int | None = None) -> None: + """Record one absolute progress update.""" + + updates.append((value, total)) + + comfy_module = ModuleType("comfy") + comfy_utils = ModuleType("comfy.utils") + comfy_utils.ProgressBar = FakeProgressBar # type: ignore[attr-defined] + comfy_module.utils = comfy_utils # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "comfy", comfy_module) + monkeypatch.setitem(sys.modules, "comfy.utils", comfy_utils) + + reporter = ComfyProgressReporter() + reporter.start("Downloading Anima text encoder", 6) + reporter.advance(3, 6) + reporter.finish() + + assert updates == [(-1, 6), (0, 6), (3, 6), (6, 6)] + + def test_downloader_streams_file_and_reports_progress( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -182,6 +222,47 @@ def test_downloader_skips_existing_file(tmp_path: Path) -> None: assert result.bytes_downloaded == 0 +def test_downloader_verifies_existing_checksum_before_skipping(tmp_path: Path) -> None: + """Checksum-pinned existing files are verified before they are trusted.""" + + destination = tmp_path / "models" / "vae" / "model.safetensors" + destination.parent.mkdir(parents=True) + destination.write_bytes(b"existing") + + result = ModelDownloader().download( + DownloadRequest( + source_url="https://example.invalid/model.safetensors", + destination_path=destination, + expected_folder=destination.parent, + description="test model", + expected_sha256=hashlib.sha256(b"existing").hexdigest(), + ) + ) + + assert result.skipped_existing is True + + +def test_downloader_rejects_existing_checksum_mismatch(tmp_path: Path) -> None: + """A wrong file at the canonical path fails closed without replacement.""" + + destination = tmp_path / "models" / "vae" / "model.safetensors" + destination.parent.mkdir(parents=True) + destination.write_bytes(b"wrong") + + with pytest.raises(ValueError, match="checksum mismatch"): + ModelDownloader().download( + DownloadRequest( + source_url="https://example.invalid/model.safetensors", + destination_path=destination, + expected_folder=destination.parent, + description="test model", + expected_sha256=hashlib.sha256(b"expected").hexdigest(), + ) + ) + + assert destination.read_bytes() == b"wrong" + + def test_downloader_removes_partial_file_on_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_registration.py b/tests/test_registration.py index a37a69d..cf35dd9 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -52,6 +52,8 @@ BASE_NODE_IDS = [ "SimpleSyrup.SimpleLoadAnima", "SimpleSyrup.SimplePreviewSEGS", "SimpleSyrup.SimpleLoadCheckpoint", + "SimpleSyrup.SimpleLoadFlux", + "SimpleSyrup.SimpleLoadFlux2", "SimpleSyrup.SimpleVAEEncode", "SimpleSyrup.TagSEGSWithExternalLLM", "SimpleSyrup.TagSEGSWithWD14", diff --git a/tests/test_simple_load_anima_node.py b/tests/test_simple_load_anima_node.py index 9855416..59b211c 100644 --- a/tests/test_simple_load_anima_node.py +++ b/tests/test_simple_load_anima_node.py @@ -13,6 +13,8 @@ from typing import Any import pytest from simple_syrup.nodes.simple_load_anima import SimpleLoadAnima +from simple_syrup.nodes_v3.legacy_node_wrappers import SimpleLoadAnimaV3 +from simple_syrup.runtime.model_downloads import ComfyProgressReporter class FakeFolderPaths(ModuleType): @@ -62,17 +64,42 @@ def test_simple_load_anima_declares_expected_inputs( "vae", ] assert required["diffusion_model"][0] == ["anima.safetensors"] + assert "advanced" not in required["diffusion_model"][1] assert required["diffusion_weight_dtype"][0] == [ "default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2", ] + assert required["diffusion_weight_dtype"][1]["advanced"] is True assert required["text_encoder"][0][0] == "auto" assert required["text_encoder"][1]["default"] == "auto" + assert required["text_encoder"][1]["advanced"] is True assert required["text_encoder_device"][0] == ["default", "cpu"] + assert required["text_encoder_device"][1]["advanced"] is True assert required["vae"][0][0] == "auto" assert required["vae"][1]["default"] == "auto" + assert required["vae"][1]["advanced"] is True + + +def test_simple_load_anima_v3_keeps_only_diffusion_model_primary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The v3 schema marks every control after diffusion selection as advanced.""" + + monkeypatch.setitem(sys.modules, "folder_paths", FakeFolderPaths()) + + schema = SimpleLoadAnimaV3.define_schema() + + assert [input_item.id for input_item in schema.inputs] == [ + "diffusion_model", + "diffusion_weight_dtype", + "text_encoder", + "text_encoder_device", + "vae", + ] + assert schema.inputs[0].advanced is None + assert all(input_item.advanced is True for input_item in schema.inputs[1:]) def test_simple_load_anima_delegates_to_service() -> None: @@ -110,4 +137,4 @@ def test_simple_load_anima_delegates_to_service() -> None: assert fake_service.kwargs is not None assert fake_service.kwargs["text_encoder"] == "auto" assert fake_service.kwargs["vae"] == "auto" - assert "progress" in fake_service.kwargs + assert isinstance(fake_service.kwargs["progress"], ComfyProgressReporter) diff --git a/tests/test_simple_load_flux_nodes.py b/tests/test_simple_load_flux_nodes.py new file mode 100644 index 0000000..d5bb19b --- /dev/null +++ b/tests/test_simple_load_flux_nodes.py @@ -0,0 +1,160 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the separate Simple Load FLUX and FLUX.2 v3 nodes.""" + +from __future__ import annotations + +import sys +from types import ModuleType + +import pytest + +from simple_syrup.nodes_v3.simple_load_flux import SimpleLoadFluxV3 +from simple_syrup.nodes_v3.simple_load_flux2 import SimpleLoadFlux2V3 +from simple_syrup.runtime.model_downloads import ComfyProgressReporter + + +class FakeFolderPaths(ModuleType): + """Provide deterministic model choices for loader schemas.""" + + def __init__(self) -> None: + """Create model lists covering every loader input family.""" + + super().__init__("folder_paths") + self.files = { + "diffusion_models": ["flux.safetensors", "flux2.safetensors"], + "text_encoders": ["manual_encoder.safetensors"], + "vae": ["manual_vae.safetensors"], + "vae_approx": [], + } + + def get_filename_list(self, folder_name: str) -> list[str]: + """Return the configured filenames for one ComfyUI model folder.""" + + return self.files[folder_name] + + +@pytest.mark.parametrize( + ("node", "node_id", "input_ids"), + ( + ( + SimpleLoadFluxV3, + "SimpleSyrup.SimpleLoadFlux", + [ + "diffusion_model", + "diffusion_weight_dtype", + "clip_l", + "t5_xxl", + "text_encoder_device", + "vae", + ], + ), + ( + SimpleLoadFlux2V3, + "SimpleSyrup.SimpleLoadFlux2", + [ + "diffusion_model", + "diffusion_weight_dtype", + "text_encoder", + "text_encoder_device", + "vae", + ], + ), + ), +) +def test_flux_loader_schema_contracts_are_separate( + monkeypatch: pytest.MonkeyPatch, + node: type[SimpleLoadFluxV3] | type[SimpleLoadFlux2V3], + node_id: str, + input_ids: list[str], +) -> None: + """Each generation exposes only the encoder choices relevant to its setup.""" + + monkeypatch.setitem(sys.modules, "folder_paths", FakeFolderPaths()) + + schema = node.define_schema() + + assert schema.node_id == node_id + assert [input_item.id for input_item in schema.inputs] == input_ids + assert [output.io_type for output in schema.outputs] == ["MODEL", "CLIP", "VAE"] + auto_inputs = [ + input_item + for input_item in schema.inputs + if input_item.id in {"clip_l", "t5_xxl", "text_encoder", "vae"} + ] + assert all(input_item.options[0] == "auto" for input_item in auto_inputs) + assert all(input_item.default == "auto" for input_item in auto_inputs) + assert schema.inputs[0].advanced is None + assert all(input_item.advanced is True for input_item in schema.inputs[1:]) + assert "download" in schema.description.lower() + + +def test_flux1_execute_supplies_comfy_progress_reporter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """FLUX.1 execution exposes automatic download progress to ComfyUI.""" + + class FakeService: + """Capture FLUX.1 execution arguments.""" + + def __init__(self) -> None: + """Create an empty keyword capture.""" + + self.kwargs: dict[str, object] = {} + + def load_models(self, **kwargs: object) -> tuple[str, str, str]: + """Capture arguments and return fixed node outputs.""" + + self.kwargs = kwargs + return "model", "clip", "vae" + + service = FakeService() + monkeypatch.setattr(SimpleLoadFluxV3, "_service", service) + + result = SimpleLoadFluxV3.execute( + "flux.safetensors", + "default", + "auto", + "auto", + "default", + "auto", + ) + + assert result == ("model", "clip", "vae") + assert isinstance(service.kwargs["progress"], ComfyProgressReporter) + + +def test_flux2_execute_supplies_comfy_progress_reporter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """FLUX.2 execution exposes automatic download progress to ComfyUI.""" + + class FakeService: + """Capture FLUX.2 execution arguments.""" + + def __init__(self) -> None: + """Create an empty keyword capture.""" + + self.kwargs: dict[str, object] = {} + + def load_models(self, **kwargs: object) -> tuple[str, str, str]: + """Capture arguments and return fixed node outputs.""" + + self.kwargs = kwargs + return "model", "clip", "vae" + + service = FakeService() + monkeypatch.setattr(SimpleLoadFlux2V3, "_service", service) + + result = SimpleLoadFlux2V3.execute( + "flux2.safetensors", + "default", + "auto", + "default", + "auto", + ) + + assert result == ("model", "clip", "vae") + assert isinstance(service.kwargs["progress"], ComfyProgressReporter) From 8104e81a29461f8955df75da59a737afa32a8ad3 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sat, 8 Aug 2026 10:35:24 -0400 Subject: [PATCH 37/40] fix(runtime): centralize Comfy patcher lifecycle --- simple_syrup/runtime/checkpoint_loader.py | 62 ++--- .../runtime/contextual_diffusion_sampling.py | 13 +- .../runtime/differential_diffusion.py | 26 +- .../runtime/mixture_of_diffusers_sampling.py | 29 ++- .../runtime/multidiffusion_sampling.py | 29 ++- simple_syrup/runtime/patcher_lifecycle.py | 216 ++++++++++++++++ simple_syrup/runtime/regional_lora_hooks.py | 20 +- .../regional_multidiffusion_sampling.py | 29 ++- simple_syrup/runtime/vae_loader.py | 7 +- tests/test_checkpoint_loader.py | 11 +- tests/test_comfy_patcher_lifecycle.py | 239 ++++++++++++++++++ tests/test_contextual_diffusion_sampling.py | 9 +- tests/test_patcher_lifecycle_policy.py | 200 +++++++++++++++ tests/test_regional_lora_hooks.py | 5 +- 14 files changed, 808 insertions(+), 87 deletions(-) create mode 100644 simple_syrup/runtime/patcher_lifecycle.py create mode 100644 tests/test_comfy_patcher_lifecycle.py create mode 100644 tests/test_patcher_lifecycle_policy.py diff --git a/simple_syrup/runtime/checkpoint_loader.py b/simple_syrup/runtime/checkpoint_loader.py index 2507df2..e395fd0 100644 --- a/simple_syrup/runtime/checkpoint_loader.py +++ b/simple_syrup/runtime/checkpoint_loader.py @@ -9,8 +9,13 @@ from __future__ import annotations import importlib from collections.abc import Iterable from types import ModuleType -from typing import Any, Protocol, cast, runtime_checkable +from typing import Any, Protocol, cast +from .patcher_lifecycle import ( + PATCHER_LIFECYCLE, + ClipLayerMutation, + ComfyPatcherLifecycle, +) from .vae_loader import VaeLoaderService USE_CHECKPOINT_VAE_CHOICE = "Use Checkpoint VAE" @@ -25,11 +30,13 @@ class CheckpointLoaderService: self, folder_paths_module: ModuleType | None = None, vae_loader: VaeLoaderBoundary | None = None, + patcher_lifecycle: ComfyPatcherLifecycle | None = None, ) -> None: """Create a checkpoint loader with injectable runtime boundaries.""" self._folder_paths_module = folder_paths_module self._vae_loader = vae_loader or VaeLoaderService(folder_paths_module) + self._patcher_lifecycle = patcher_lifecycle or PATCHER_LIFECYCLE def load_checkpoint( self, @@ -57,12 +64,32 @@ class CheckpointLoaderService: model = loaded[0] clip = loaded[1] checkpoint_vae = loaded[2] - selected_clip = _selected_clip(clip, clip_skip) + selected_clip = self._selected_clip(clip, clip_skip) if vae_name == USE_CHECKPOINT_VAE_CHOICE: - return model, selected_clip, checkpoint_vae + selected_vae = checkpoint_vae + else: + selected_vae = self._vae_loader.load_vae(vae_name) - return model, selected_clip, self._vae_loader.load_vae(vae_name) + return ( + model, + selected_clip, + self._patcher_lifecycle.preserve_vae( + selected_vae, + operation="SimpleSyrup checkpoint loading", + ), + ) + + def _selected_clip(self, clip: object, clip_skip: bool) -> object: + """Return the loaded CLIP or a lifecycle-owned clip-skip derivation.""" + + if not clip_skip: + return clip + return self._patcher_lifecycle.derive_clip( + clip, + (ClipLayerMutation(CLIP_SKIP_LAYER),), + operation="SimpleSyrup checkpoint clip skip", + ) def _folder_paths(self) -> ModuleType: """Return the ComfyUI folder_paths module.""" @@ -83,17 +110,6 @@ class VaeLoaderBoundary(Protocol): """Load the named external VAE.""" -@runtime_checkable -class ClipLayerBoundary(Protocol): - """CLIP interface required to apply the ComfyUI clip-skip layer.""" - - def clone(self) -> ClipLayerBoundary: - """Return an independent CLIP object.""" - - def clip_layer(self, layer_idx: int) -> None: - """Set the CLIP layer index used during prompt encoding.""" - - def _validate_clip_skip(clip_skip: object) -> None: """Reject non-boolean clip-skip selections from runtime callers.""" @@ -101,22 +117,6 @@ def _validate_clip_skip(clip_skip: object) -> None: raise TypeError("clip_skip must be a boolean.") -def _selected_clip(clip: object, clip_skip: bool) -> object: - """Return the loaded CLIP or a cloned CLIP with clip skip applied.""" - - if not clip_skip: - return clip - - if not isinstance(clip, ClipLayerBoundary): - raise TypeError( - "clip_skip requires a CLIP object with clone() and clip_layer()." - ) - - selected_clip = clip.clone() - selected_clip.clip_layer(CLIP_SKIP_LAYER) - return selected_clip - - def _comfy_sd() -> Any: """Import ComfyUI's stable diffusion loading module lazily.""" diff --git a/simple_syrup/runtime/contextual_diffusion_sampling.py b/simple_syrup/runtime/contextual_diffusion_sampling.py index 53401ce..82017c6 100644 --- a/simple_syrup/runtime/contextual_diffusion_sampling.py +++ b/simple_syrup/runtime/contextual_diffusion_sampling.py @@ -18,6 +18,7 @@ from ..domain.contextual_diffusion import ( ) from ..shared.logging import get_logger from . import sampling_samplers, sampling_schedulers +from .patcher_lifecycle import PATCHER_LIFECYCLE, ModelUnetWrapperMutation from .tiled_sampling import ( ApplyModel, Latent, @@ -156,10 +157,9 @@ def clone_model_with_contextual_diffusion( sigmas: torch.Tensor, diffusion_mode: str, ) -> Any: - """Clone a model and install one pre-CFG contextual prediction wrapper.""" + """Derive a model with one pre-CFG contextual prediction wrapper.""" - cloned_model = model.clone() - old_wrapper = cloned_model.model_options.get("model_function_wrapper") + old_wrapper = model.model_options.get("model_function_wrapper") if old_wrapper is not None and not callable(old_wrapper): raise ValueError("Existing model_function_wrapper is not callable.") wrapper = ContextualDiffusionModelWrapper( @@ -169,8 +169,11 @@ def clone_model_with_contextual_diffusion( diffusion_mode=diffusion_mode, existing_wrapper=cast(ModelFunctionWrapper | None, old_wrapper), ) - cloned_model.set_model_unet_function_wrapper(wrapper) - return cloned_model + return PATCHER_LIFECYCLE.derive_model( + model, + (ModelUnetWrapperMutation(wrapper),), + operation="SimpleSyrup contextual diffusion", + ) class ContextualDiffusionModelWrapper: diff --git a/simple_syrup/runtime/differential_diffusion.py b/simple_syrup/runtime/differential_diffusion.py index 063a423..ba3ba0e 100644 --- a/simple_syrup/runtime/differential_diffusion.py +++ b/simple_syrup/runtime/differential_diffusion.py @@ -9,6 +9,8 @@ from __future__ import annotations from importlib import import_module from typing import Any +from .patcher_lifecycle import PATCHER_LIFECYCLE, ModelDenoiseMaskMutation + def has_denoise_mask_function(model: Any) -> bool: """Return whether a model patcher already has denoise-mask behavior.""" @@ -20,31 +22,29 @@ def has_denoise_mask_function(model: Any) -> bool: def clone_with_differential_diffusion(model: Any, strength: float = 1.0) -> Any: - """Return a clone patched with ComfyUI differential denoise masks.""" + """Return a lifecycle-owned MODEL with differential denoise masks.""" if has_denoise_mask_function(model): return model - cloned_model = model.clone() - install_differential_diffusion(cloned_model, strength=strength) - return cloned_model + return PATCHER_LIFECYCLE.derive_model( + model, + (differential_diffusion_mutation(strength=strength),), + operation="SimpleSyrup differential diffusion", + ) -def install_differential_diffusion(model: Any, strength: float = 1.0) -> Any: - """Install ComfyUI differential denoise-mask behavior on a model patcher.""" +def differential_diffusion_mutation( + strength: float = 1.0, +) -> ModelDenoiseMaskMutation: + """Build the ComfyUI differential denoise-mask mutation.""" - if has_denoise_mask_function(model): - return model - set_mask_function = getattr(model, "set_model_denoise_mask_function", None) - if not callable(set_mask_function): - raise ValueError("Model does not support differential diffusion denoise masks.") differential_diffusion = import_module( "comfy_extras.nodes_differential_diffusion" ).DifferentialDiffusion - set_mask_function( + return ModelDenoiseMaskMutation( lambda *args, **kwargs: differential_diffusion.forward( *args, **kwargs, strength=strength, ) ) - return model diff --git a/simple_syrup/runtime/mixture_of_diffusers_sampling.py b/simple_syrup/runtime/mixture_of_diffusers_sampling.py index 917b7ac..3d084d5 100644 --- a/simple_syrup/runtime/mixture_of_diffusers_sampling.py +++ b/simple_syrup/runtime/mixture_of_diffusers_sampling.py @@ -23,7 +23,15 @@ from ..domain.tiled_diffusion import ( from ..shared.logging import get_logger from . import sampling_samplers, sampling_schedulers from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback -from .differential_diffusion import install_differential_diffusion +from .differential_diffusion import ( + differential_diffusion_mutation, + has_denoise_mask_function, +) +from .patcher_lifecycle import ( + PATCHER_LIFECYCLE, + ModelMutation, + ModelUnetWrapperMutation, +) from .tiled_sampling import ( ApplyModel, Latent, @@ -176,7 +184,7 @@ def clone_model_with_mixture_of_diffusers( differential_diffusion: bool = False, tiled_plan: TiledDiffusionPlan | None = None, ) -> tuple[Any, TiledDiffusionPlan]: - """Return a model clone patched with a pre-CFG Mixture wrapper.""" + """Return a derived model patched with a pre-CFG Mixture wrapper.""" plan = tiled_plan or build_tiled_diffusion_plan( latent_width=latent_width, @@ -187,10 +195,7 @@ def clone_model_with_mixture_of_diffusers( tile_batch_size=tile_batch_size, ) _validate_supplied_plan(plan, latent_width, latent_height) - cloned_model = model.clone() - if differential_diffusion: - install_differential_diffusion(cloned_model) - old_wrapper = cloned_model.model_options.get("model_function_wrapper") + old_wrapper = model.model_options.get("model_function_wrapper") if old_wrapper is not None and not callable(old_wrapper): raise ValueError("Existing model_function_wrapper is not callable.") @@ -198,8 +203,16 @@ def clone_model_with_mixture_of_diffusers( plan=plan, existing_wrapper=cast(ModelFunctionWrapper | None, old_wrapper), ) - cloned_model.set_model_unet_function_wrapper(wrapper) - return cloned_model, plan + mutations: list[ModelMutation] = [] + if differential_diffusion and not has_denoise_mask_function(model): + mutations.append(differential_diffusion_mutation()) + mutations.append(ModelUnetWrapperMutation(wrapper)) + derived_model = PATCHER_LIFECYCLE.derive_model( + model, + mutations, + operation="SimpleSyrup Mixture of Diffusers", + ) + return derived_model, plan class MixtureOfDiffusersModelWrapper: diff --git a/simple_syrup/runtime/multidiffusion_sampling.py b/simple_syrup/runtime/multidiffusion_sampling.py index 5181b19..0ce1924 100644 --- a/simple_syrup/runtime/multidiffusion_sampling.py +++ b/simple_syrup/runtime/multidiffusion_sampling.py @@ -23,7 +23,15 @@ from ..domain.tiled_diffusion import ( from ..shared.logging import get_logger from . import sampling_samplers, sampling_schedulers from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback -from .differential_diffusion import install_differential_diffusion +from .differential_diffusion import ( + differential_diffusion_mutation, + has_denoise_mask_function, +) +from .patcher_lifecycle import ( + PATCHER_LIFECYCLE, + ModelMutation, + ModelUnetWrapperMutation, +) from .tiled_sampling import ( ApplyModel, Latent, @@ -179,7 +187,7 @@ def clone_model_with_multidiffusion( differential_diffusion: bool = False, tiled_plan: TiledDiffusionPlan | None = None, ) -> tuple[Any, TiledDiffusionPlan]: - """Return a model clone patched with a pre-CFG MultiDiffusion wrapper.""" + """Return a derived model patched with a pre-CFG MultiDiffusion wrapper.""" plan = tiled_plan or build_tiled_diffusion_plan( latent_width=latent_width, @@ -190,10 +198,7 @@ def clone_model_with_multidiffusion( tile_batch_size=tile_batch_size, ) _validate_supplied_plan(plan, latent_width, latent_height) - cloned_model = model.clone() - if differential_diffusion: - install_differential_diffusion(cloned_model) - old_wrapper = cloned_model.model_options.get("model_function_wrapper") + old_wrapper = model.model_options.get("model_function_wrapper") if old_wrapper is not None and not callable(old_wrapper): raise ValueError("Existing model_function_wrapper is not callable.") @@ -201,8 +206,16 @@ def clone_model_with_multidiffusion( plan=plan, existing_wrapper=cast(ModelFunctionWrapper | None, old_wrapper), ) - cloned_model.set_model_unet_function_wrapper(wrapper) - return cloned_model, plan + mutations: list[ModelMutation] = [] + if differential_diffusion and not has_denoise_mask_function(model): + mutations.append(differential_diffusion_mutation()) + mutations.append(ModelUnetWrapperMutation(wrapper)) + derived_model = PATCHER_LIFECYCLE.derive_model( + model, + mutations, + operation="SimpleSyrup MultiDiffusion", + ) + return derived_model, plan class MultiDiffusionModelWrapper: diff --git a/simple_syrup/runtime/patcher_lifecycle.py b/simple_syrup/runtime/patcher_lifecycle.py new file mode 100644 index 0000000..90b1628 --- /dev/null +++ b/simple_syrup/runtime/patcher_lifecycle.py @@ -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 + +"""Own lifecycle-safe derivation of ComfyUI MODEL and CLIP values.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import Any, Protocol, TypeVar, cast + + +class ModelMutation(Protocol): + """Apply one supported mutation to an already-derived MODEL patcher.""" + + def apply(self, model: object) -> None: + """Apply the mutation through ComfyUI's public patcher API.""" + + +class ClipMutation(Protocol): + """Apply one supported mutation to an already-derived CLIP value.""" + + def apply(self, clip: object) -> None: + """Apply the mutation through ComfyUI's public CLIP API.""" + + +@dataclass(frozen=True) +class ModelDenoiseMaskMutation: + """Install a denoise-mask function through the MODEL patcher API.""" + + function: Callable[..., object] + + def apply(self, model: object) -> None: + """Install the configured denoise-mask function.""" + + setter = getattr(model, "set_model_denoise_mask_function", None) + if not callable(setter): + raise TypeError("MODEL does not support denoise-mask functions.") + setter(self.function) + + +@dataclass(frozen=True) +class ModelUnetWrapperMutation: + """Install a model-function wrapper through the MODEL patcher API.""" + + wrapper: Callable[..., object] + + def apply(self, model: object) -> None: + """Install the configured model-function wrapper.""" + + setter = getattr(model, "set_model_unet_function_wrapper", None) + if not callable(setter): + raise TypeError("MODEL does not support model-function wrappers.") + setter(self.wrapper) + + +@dataclass(frozen=True) +class ModelCalcCondBatchMutation: + """Install a calc-cond-batch function through the MODEL patcher API.""" + + function: Callable[..., object] + + def apply(self, model: object) -> None: + """Install the configured calc-cond-batch function.""" + + setter = getattr(model, "set_model_sampler_calc_cond_batch_function", None) + if not callable(setter): + raise TypeError("MODEL does not support calc-cond-batch functions.") + setter(self.function) + + +@dataclass(frozen=True) +class ClipLayerMutation: + """Select the text-encoder layer used by a derived CLIP value.""" + + layer_index: int + + def apply(self, clip: object) -> None: + """Apply the configured CLIP layer index.""" + + select_layer = getattr(clip, "clip_layer", None) + if not callable(select_layer): + raise TypeError("CLIP does not support layer selection.") + select_layer(self.layer_index) + + +@dataclass(frozen=True) +class ClipHookScheduleMutation: + """Install a native ComfyUI hook schedule on a derived CLIP value.""" + + hooks: object + target: object + + def apply(self, clip: object) -> None: + """Clone and register hooks through the derived CLIP patcher.""" + + patcher = _required_attribute(clip, "patcher", value_name="CLIP") + clone_hooks = getattr(self.hooks, "clone", None) + if not callable(clone_hooks): + raise TypeError("HOOKS does not support lifecycle-safe cloning.") + register_hooks = getattr(patcher, "register_all_hook_patches", None) + if not callable(register_hooks): + raise TypeError("CLIP patcher does not support hook registration.") + + patcher_boundary = cast(Any, patcher) + clip_boundary = cast(Any, clip) + patcher_boundary.forced_hooks = clone_hooks() + clip_boundary.use_clip_schedule = True + register_hooks(self.hooks, self.target) + + +PatcherValue = TypeVar("PatcherValue") + + +class ComfyPatcherLifecycle: + """Derive Comfy patchers while preserving their source lineage.""" + + def derive_model( + self, + source: PatcherValue, + mutations: Iterable[ModelMutation], + *, + operation: str, + ) -> PatcherValue: + """Clone one MODEL, verify its lineage, and apply all mutations.""" + + derived = self._clone(source, operation=operation) + self._require_direct_parent(source, derived, operation=operation) + for mutation in mutations: + mutation.apply(derived) + return derived + + def derive_clip( + self, + source: PatcherValue, + mutations: Iterable[ClipMutation], + *, + operation: str, + disable_dynamic: bool = False, + ) -> PatcherValue: + """Clone one CLIP, verify patcher lineage, and apply all mutations.""" + + clone = getattr(source, "clone", None) + if not callable(clone): + raise TypeError(f"{operation} requires a cloneable CLIP value.") + derived = cast( + PatcherValue, + clone(disable_dynamic=disable_dynamic) if disable_dynamic else clone(), + ) + if derived is source: + raise RuntimeError(f"{operation} returned the source CLIP from clone().") + + source_patcher = _required_attribute( + source, + "patcher", + value_name="source CLIP", + ) + derived_patcher = _required_attribute( + derived, + "patcher", + value_name="derived CLIP", + ) + self._require_direct_parent( + source_patcher, + derived_patcher, + operation=operation, + ) + for mutation in mutations: + mutation.apply(derived) + return derived + + def preserve_vae(self, vae: PatcherValue, *, operation: str) -> PatcherValue: + """Return an unmodified VAE and make the no-derivation contract explicit.""" + + if not operation.strip(): + raise ValueError("VAE lifecycle operations require a descriptive name.") + return vae + + @staticmethod + def _clone(source: PatcherValue, *, operation: str) -> PatcherValue: + """Clone one MODEL through its ComfyUI boundary.""" + + clone = getattr(source, "clone", None) + if not callable(clone): + raise TypeError(f"{operation} requires a cloneable MODEL value.") + derived = cast(PatcherValue, clone()) + if derived is source: + raise RuntimeError(f"{operation} returned the source MODEL from clone().") + return derived + + @staticmethod + def _require_direct_parent( + source: object, + derived: object, + *, + operation: str, + ) -> None: + """Require Comfy's parent link used by loaded-model cleanup.""" + + if getattr(derived, "parent", None) is not source: + raise RuntimeError( + f"{operation} produced a derived patcher without its source as parent." + ) + + +PATCHER_LIFECYCLE = ComfyPatcherLifecycle() + + +def _required_attribute(value: object, name: str, *, value_name: str) -> object: + """Return a required dynamic ComfyUI boundary attribute.""" + + attribute = getattr(value, name, None) + if attribute is None: + raise TypeError(f"{value_name} does not expose {name}.") + return attribute diff --git a/simple_syrup/runtime/regional_lora_hooks.py b/simple_syrup/runtime/regional_lora_hooks.py index 4929f4b..20d4d6d 100644 --- a/simple_syrup/runtime/regional_lora_hooks.py +++ b/simple_syrup/runtime/regional_lora_hooks.py @@ -10,6 +10,8 @@ import logging from importlib import import_module from typing import Any +from .patcher_lifecycle import PATCHER_LIFECYCLE, ClipHookScheduleMutation + LOGGER = logging.getLogger(__name__) @@ -32,12 +34,18 @@ def prepare_regional_lora_clip(clip: Any, hooks: object) -> tuple[Any, object]: "text-encoder hook group entries.", matching_hook_count, ) - prepared_clip = clip.clone(disable_dynamic=True) - prepared_clip.patcher.forced_hooks = hooks.clone() - prepared_clip.use_clip_schedule = True - prepared_clip.patcher.register_all_hook_patches( - hooks, - comfy_hooks.create_target_dict(comfy_hooks.EnumWeightTarget.Clip), + prepared_clip = PATCHER_LIFECYCLE.derive_clip( + clip, + ( + ClipHookScheduleMutation( + hooks=hooks, + target=comfy_hooks.create_target_dict( + comfy_hooks.EnumWeightTarget.Clip + ), + ), + ), + operation="SimpleSyrup regional LoRA CLIP preparation", + disable_dynamic=True, ) return prepared_clip, hooks diff --git a/simple_syrup/runtime/regional_multidiffusion_sampling.py b/simple_syrup/runtime/regional_multidiffusion_sampling.py index e06fa91..0b63ddd 100644 --- a/simple_syrup/runtime/regional_multidiffusion_sampling.py +++ b/simple_syrup/runtime/regional_multidiffusion_sampling.py @@ -22,7 +22,15 @@ from ..domain.regional_detailing import LatentRegion from ..shared.logging import get_logger from . import sampling_samplers, sampling_schedulers from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback -from .differential_diffusion import install_differential_diffusion +from .differential_diffusion import ( + differential_diffusion_mutation, + has_denoise_mask_function, +) +from .patcher_lifecycle import ( + PATCHER_LIFECYCLE, + ModelCalcCondBatchMutation, + ModelMutation, +) from .tiled_sampling import ( Latent, reject_unsupported_conditioning, @@ -168,7 +176,7 @@ def clone_model_with_regional_multidiffusion( global_prompt_weight: float = 0.0, differential_diffusion: bool = False, ) -> tuple[Any, RegionalMultiDiffusionSummary]: - """Return a model clone patched with regional calc-cond-batch blending.""" + """Return a derived model patched with regional calc-cond-batch blending.""" _validate_sampling_controls( steps=1, @@ -180,10 +188,7 @@ def clone_model_with_regional_multidiffusion( latent_height=latent_height, regions=regions, ) - cloned_model = model.clone() - if differential_diffusion: - install_differential_diffusion(cloned_model) - old_wrapper = cloned_model.model_options.get("sampler_calc_cond_batch_function") + old_wrapper = model.model_options.get("sampler_calc_cond_batch_function") if old_wrapper is not None and not callable(old_wrapper): raise ValueError("Existing sampler_calc_cond_batch_function is not callable.") @@ -194,7 +199,15 @@ def clone_model_with_regional_multidiffusion( existing_calc_cond_batch=cast(CalcCondBatchFunction | None, old_wrapper), global_prompt_weight=global_prompt_weight, ) - cloned_model.set_model_sampler_calc_cond_batch_function(wrapper) + mutations: list[ModelMutation] = [] + if differential_diffusion and not has_denoise_mask_function(model): + mutations.append(differential_diffusion_mutation()) + mutations.append(ModelCalcCondBatchMutation(wrapper)) + derived_model = PATCHER_LIFECYCLE.derive_model( + model, + mutations, + operation="SimpleSyrup regional MultiDiffusion", + ) summary = RegionalMultiDiffusionSummary( latent_width=latent_width, latent_height=latent_height, @@ -208,7 +221,7 @@ def clone_model_with_regional_multidiffusion( default=0, ), ) - return cloned_model, summary + return derived_model, summary class RegionalMultiDiffusionCalcCondBatch: diff --git a/simple_syrup/runtime/vae_loader.py b/simple_syrup/runtime/vae_loader.py index 23fde5a..2b63b64 100644 --- a/simple_syrup/runtime/vae_loader.py +++ b/simple_syrup/runtime/vae_loader.py @@ -14,6 +14,8 @@ from typing import Any import torch +from .patcher_lifecycle import PATCHER_LIFECYCLE + VIDEO_TAES = ("taehv", "lighttaew2_2", "lighttaew2_1", "lighttaehy1_5", "taeltx_2") IMAGE_TAES = ("taesd", "taesdxl", "taesd3", "taef1") @@ -156,7 +158,10 @@ def _build_vae(sd: dict[str, object], metadata: object | None) -> object: comfy_sd = _comfy_sd() vae = comfy_sd.VAE(sd=sd, metadata=metadata) vae.throw_exception_if_invalid() - return vae + return PATCHER_LIFECYCLE.preserve_vae( + vae, + operation="SimpleSyrup VAE loading", + ) def _folder_paths() -> ModuleType: diff --git a/tests/test_checkpoint_loader.py b/tests/test_checkpoint_loader.py index db5380d..4c579aa 100644 --- a/tests/test_checkpoint_loader.py +++ b/tests/test_checkpoint_loader.py @@ -9,7 +9,7 @@ from __future__ import annotations import sys from dataclasses import dataclass, field from pathlib import Path -from types import ModuleType +from types import ModuleType, SimpleNamespace import pytest @@ -24,18 +24,23 @@ from simple_syrup.runtime.checkpoint_loader import ( class FakeClip: """CLIP double that records clone and layer selection behavior.""" - def __init__(self, name: str = "checkpoint_clip") -> None: + def __init__( + self, + name: str = "checkpoint_clip", + parent_patcher: object | None = None, + ) -> None: """Create a CLIP double with no selected layer.""" self.name = name self.layer: int | None = None self.clone_count = 0 + self.patcher = SimpleNamespace(parent=parent_patcher) def clone(self) -> FakeClip: """Return an independent CLIP double and record the clone call.""" self.clone_count += 1 - return FakeClip(f"{self.name}_clone") + return FakeClip(f"{self.name}_clone", parent_patcher=self.patcher) def clip_layer(self, layer: int) -> None: """Record the selected CLIP layer.""" diff --git a/tests/test_comfy_patcher_lifecycle.py b/tests/test_comfy_patcher_lifecycle.py new file mode 100644 index 0000000..54b8e1f --- /dev/null +++ b/tests/test_comfy_patcher_lifecycle.py @@ -0,0 +1,239 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Characterize ComfyUI's patcher lifecycle leak detector.""" + +from __future__ import annotations + +import gc +import logging +import weakref +from typing import Any + +import pytest +import torch + +from simple_syrup.runtime.patcher_lifecycle import ( + ClipLayerMutation, + ComfyPatcherLifecycle, + ModelCalcCondBatchMutation, + ModelDenoiseMaskMutation, + ModelUnetWrapperMutation, +) + + +class AnimaTEModel_(torch.nn.Module): + """Represent a tiny Anima text encoder without production weights.""" + + def __init__(self) -> None: + """Create one parameter so ComfyUI sees a normal torch module.""" + + super().__init__() + self.projection = torch.nn.Linear(1, 1) + + +class _AnimaClip: + """Expose an Anima-style encoder through the native CLIP clone shape.""" + + def __init__(self, patcher: Any, encoder: AnimaTEModel_) -> None: + """Create a CLIP value around one real Comfy ModelPatcher.""" + + self.patcher = patcher + self.cond_stage_model = encoder + self.layer_index: int | None = None + + def clone(self, disable_dynamic: bool = False) -> _AnimaClip: + """Clone the patcher with the same behavior as Comfy's CLIP wrapper.""" + + return _AnimaClip( + self.patcher.clone(disable_dynamic=disable_dynamic), + self.cond_stage_model, + ) + + def clip_layer(self, layer_index: int) -> None: + """Record the selected text-encoder layer.""" + + self.layer_index = layer_index + + +def test_real_comfy_anima_lifecycle_regression( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Exercise the unsafe predicate and every safe owner path in one worker.""" + + _assert_comfy_marks_rootless_anima_patcher_dead() + _assert_comfy_returns_clone_to_live_source() + _assert_lifecycle_owned_anima_clip_is_safe(caplog, monkeypatch) + _assert_supported_model_mutations_share_one_clone() + + +def _assert_comfy_marks_rootless_anima_patcher_dead() -> None: + """Capture the exact condition behind ComfyUI's memory-leak warning.""" + + from comfy.model_management import LoadedModel + + encoder = AnimaTEModel_() + patcher = _patcher(encoder) + loaded = LoadedModel(patcher) + loaded.real_model = weakref.ref(encoder) + + del patcher + gc.collect() + + assert loaded.model is None + assert loaded.real_model() is encoder + assert loaded.is_dead() is True + + +def _assert_comfy_returns_clone_to_live_source() -> None: + """Prove valid clone lineage cannot satisfy the stale-patcher condition.""" + + from comfy.model_management import LoadedModel + + encoder = AnimaTEModel_() + source = _patcher(encoder) + derived = source.clone() + loaded = LoadedModel(derived) + loaded.real_model = weakref.ref(encoder) + + del derived + gc.collect() + + assert loaded.model is source + assert loaded.is_dead() is False + + +def _assert_lifecycle_owned_anima_clip_is_safe( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A released first-party Anima CLIP derivation resolves to its live source.""" + + import comfy.model_management + from comfy.model_management import LoadedModel + + encoder = AnimaTEModel_() + source_clip = _AnimaClip(_patcher(encoder), encoder) + derived_clip = ComfyPatcherLifecycle().derive_clip( + source_clip, + (ClipLayerMutation(-2),), + operation="Anima CLIP regression", + ) + assert isinstance(derived_clip, _AnimaClip) + assert derived_clip.layer_index == -2 + loaded = LoadedModel(derived_clip.patcher) + loaded.real_model = weakref.ref(encoder) + monkeypatch.setattr(comfy.model_management, "current_loaded_models", [loaded]) + + del derived_clip + gc.collect() + with caplog.at_level(logging.INFO): + comfy.model_management.cleanup_models_gc() + + assert loaded.model is source_clip.patcher + assert loaded.is_dead() is False + assert "Potential memory leak detected" not in caplog.text + assert "WARNING, memory leak" not in caplog.text + + caplog.clear() + released_encoder = AnimaTEModel_() + released_source = _AnimaClip(_patcher(released_encoder), released_encoder) + released_clip = ComfyPatcherLifecycle().derive_clip( + released_source, + (ClipLayerMutation(-2),), + operation="released Anima CLIP regression", + ) + released = LoadedModel(released_clip.patcher) + released.real_model = weakref.ref(released_encoder) + monkeypatch.setattr(comfy.model_management, "current_loaded_models", [released]) + + del released_clip, released_source, released_encoder + gc.collect() + with caplog.at_level(logging.INFO): + comfy.model_management.cleanup_models_gc() + + assert released.model is None + assert released.real_model() is None + assert released.is_dead() is False + assert "Potential memory leak detected" not in caplog.text + assert "WARNING, memory leak" not in caplog.text + + +def _assert_supported_model_mutations_share_one_clone() -> None: + """All supported first-party MODEL changes share one verified derivation.""" + + source = _patcher(torch.nn.Linear(1, 1)) + + def denoise_mask(*args: object, **kwargs: object) -> object: + """Return a stable test sentinel.""" + + del args, kwargs + return object() + + def model_wrapper(args: object) -> object: + """Return the supplied model-wrapper arguments.""" + + return args + + def calc_cond_batch(args: object) -> object: + """Return the supplied calc-cond-batch arguments.""" + + return args + + derived = ComfyPatcherLifecycle().derive_model( + source, + ( + ModelDenoiseMaskMutation(denoise_mask), + ModelUnetWrapperMutation(model_wrapper), + ModelCalcCondBatchMutation(calc_cond_batch), + ), + operation="MODEL mutation regression", + ) + + assert derived.parent is source + assert derived.model_options["denoise_mask_function"] is denoise_mask + assert derived.model_options["model_function_wrapper"] is model_wrapper + assert derived.model_options["sampler_calc_cond_batch_function"] is calc_cond_batch + + +def test_lifecycle_rejects_a_clone_without_comfy_parent_lineage() -> None: + """A non-Comfy clone cannot silently enter a first-party lifecycle path.""" + + class BrokenModel: + """Return an unrelated object from clone().""" + + def clone(self) -> BrokenModel: + """Return a clone without a parent link.""" + + return BrokenModel() + + with pytest.raises(RuntimeError, match="without its source as parent"): + ComfyPatcherLifecycle().derive_model( + BrokenModel(), + (), + operation="broken regression", + ) + + +def test_lifecycle_preserves_vae_identity() -> None: + """The first-party VAE contract never clones or mutates the supplied value.""" + + vae = object() + + result = ComfyPatcherLifecycle().preserve_vae( + vae, + operation="VAE regression", + ) + + assert result is vae + + +def _patcher(model: torch.nn.Module) -> Any: + """Create a CPU patcher without loading model weights.""" + + from comfy.model_patcher import ModelPatcher + + device = torch.device("cpu") + return ModelPatcher(model, load_device=device, offload_device=device) diff --git a/tests/test_contextual_diffusion_sampling.py b/tests/test_contextual_diffusion_sampling.py index cf13be3..f366c6b 100644 --- a/tests/test_contextual_diffusion_sampling.py +++ b/tests/test_contextual_diffusion_sampling.py @@ -323,17 +323,22 @@ def _controls( class _FakeModel: """Provide the ModelPatcher surface used by the semantic runtime.""" - def __init__(self, model_options: dict[str, Any] | None = None) -> None: + def __init__( + self, + model_options: dict[str, Any] | None = None, + parent: _FakeModel | None = None, + ) -> None: """Create a CPU-backed fake model patcher.""" self.load_device = torch.device("cpu") self.model_options = {} if model_options is None else model_options self.wrapper: object | None = None + self.parent = parent def clone(self) -> _FakeModel: """Return a clone with copied model options.""" - return _FakeModel(self.model_options.copy()) + return _FakeModel(self.model_options.copy(), parent=self) def set_model_unet_function_wrapper(self, wrapper: object) -> None: """Capture the installed wrapper.""" diff --git a/tests/test_patcher_lifecycle_policy.py b/tests/test_patcher_lifecycle_policy.py new file mode 100644 index 0000000..d13922b --- /dev/null +++ b/tests/test_patcher_lifecycle_policy.py @@ -0,0 +1,200 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Enforce the single first-party Comfy patcher lifecycle boundary.""" + +from __future__ import annotations + +import ast +from collections import Counter +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SOURCE_ROOT = PROJECT_ROOT / "simple_syrup" +LIFECYCLE_MODULE = "simple_syrup/runtime/patcher_lifecycle.py" +FORBIDDEN_PATCHER_CALLS = frozenset( + { + "add_object_patch", + "add_patches", + "clip_layer", + "register_all_hook_patches", + "set_tokenizer_option", + } +) +FORBIDDEN_PATCHER_WRITES = frozenset({"forced_hooks", "use_clip_schedule"}) +APPROVED_VALUE_CLONES = Counter( + { + ("simple_syrup/domain/segs_tiled_diffusion.py", "mask"): 2, + ("simple_syrup/image/crop_composite.py", "image"): 1, + ( + "simple_syrup/image/resize_service.py", + "values.expand(cropped.shape[0], cropped.shape[1], " + "plan.output_height, plan.output_width)", + ): 1, + ( + "simple_syrup/masking/prompt_segs_with_sam_service.py", + "crop_image(image_tensor, crop_region).detach()", + ): 1, + ( + "simple_syrup/masking/prompt_segs_with_sam_service.py", + "crop_mask(final_mask, crop_region).detach()", + ): 1, + ("simple_syrup/runtime/sam_region_overlay_renderer.py", "image.detach()"): 2, + ( + "simple_syrup/services/detail_segs_as_regions_service.py", + "image_tensor", + ): 1, + ( + "simple_syrup/services/detail_segs_by_scale_factor_service.py", + "image_tensor", + ): 2, + ( + "simple_syrup/services/detail_segs_by_scale_factor_tiled_diffusion_service.py", + "image_tensor", + ): 2, + ( + "simple_syrup/services/mask_to_segs_service.py", + "crop_image(image_tensor, crop_region).detach()", + ): 1, + ( + "simple_syrup/services/mask_to_segs_service.py", + "cropped_segment_mask", + ): 1, + ( + "simple_syrup/services/segs_detection_service.py", + "crop_mask(mask, crop_region).detach()", + ): 1, + ( + "simple_syrup/services/segs_detection_service.py", + "crop_image(image_tensor, crop_region).detach()", + ): 1, + ( + "simple_syrup/services/segs_from_sam_output_service.py", + "image[:, crop_region.top:crop_region.bottom, " + "crop_region.left:crop_region.right, :].detach()", + ): 1, + ( + "simple_syrup/services/segs_from_sam_output_service.py", + "local_mask.unsqueeze(0).detach()", + ): 1, + ( + "simple_syrup/services/segs_output_service.py", + "crop_mask(combined_mask, crop_region).detach()", + ): 1, + ( + "simple_syrup/services/segs_output_service.py", + "crop_image(image_tensor, crop_region).detach()", + ): 1, + ( + "simple_syrup/services/simple_preview_segs_service.py", + "image.detach().cpu()", + ): 1, + } +) + + +def test_first_party_patcher_lifecycle_has_one_authoritative_owner() -> None: + """Reject every first-party clone or patcher mutation outside the owner.""" + + observed_clones: Counter[tuple[str, str]] = Counter() + violations: list[str] = [] + for path in sorted(SOURCE_ROOT.rglob("*.py")): + relative_path = path.relative_to(PROJECT_ROOT).as_posix() + if "third_party" in path.parts or relative_path == LIFECYCLE_MODULE: + continue + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + file_violations, clones = _lifecycle_bypasses(tree, relative_path) + violations.extend(file_violations) + observed_clones.update(clones) + + unexpected_clones = observed_clones - APPROVED_VALUE_CLONES + missing_clones = APPROVED_VALUE_CLONES - observed_clones + assert violations == [] + assert unexpected_clones == Counter() + assert missing_clones == Counter() + + +def test_policy_detects_a_new_direct_patcher_code_path() -> None: + """Prove the policy rejects clone, mutation, and object-patch bypasses.""" + + source = """ +def unsafe(model): + cloned = model.clone() + cloned.set_model_unet_function_wrapper(lambda args: args) + cloned.add_object_patch("encode", model.encode) + cloned.forced_hooks = object() + return cloned +""" + violations, clones = _lifecycle_bypasses( + ast.parse(source), + "simple_syrup/runtime/new_feature.py", + ) + + assert len(violations) == 3 + assert clones == Counter({("simple_syrup/runtime/new_feature.py", "model"): 1}) + + +def _lifecycle_bypasses( + tree: ast.AST, + relative_path: str, +) -> tuple[list[str], Counter[tuple[str, str]]]: + """Return direct patcher mutations and all clone callsites in one tree.""" + + violations: list[str] = [] + clones: Counter[tuple[str, str]] = Counter() + for node in ast.walk(tree): + if isinstance(node, ast.Attribute): + if node.attr == "clone": + clones[(relative_path, ast.unparse(node.value))] += 1 + elif _is_forbidden_patcher_call(node.attr): + violations.append(f"{relative_path}:{node.lineno}: {node.attr}") + elif isinstance(node, ast.Call) and _uses_forbidden_dynamic_attribute(node): + violations.append(f"{relative_path}:{node.lineno}: dynamic patcher access") + elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + for target in _assignment_targets(node): + if ( + isinstance(target, ast.Attribute) + and target.attr in FORBIDDEN_PATCHER_WRITES + ): + violations.append( + f"{relative_path}:{node.lineno}: write {target.attr}" + ) + return violations, clones + + +def _uses_forbidden_dynamic_attribute(node: ast.Call) -> bool: + """Return whether getattr or setattr hides a protected patcher attribute.""" + + if not isinstance(node.func, ast.Name) or node.func.id not in { + "getattr", + "setattr", + }: + return False + if len(node.args) < 2 or not isinstance(node.args[1], ast.Constant): + return False + attribute_name = node.args[1].value + return isinstance(attribute_name, str) and ( + _is_forbidden_patcher_call(attribute_name) + or attribute_name in FORBIDDEN_PATCHER_WRITES + ) + + +def _is_forbidden_patcher_call(attribute_name: str) -> bool: + """Return whether an attribute mutates a managed MODEL or CLIP value.""" + + return ( + attribute_name.startswith("set_model_") + or attribute_name in FORBIDDEN_PATCHER_CALLS + ) + + +def _assignment_targets( + node: ast.Assign | ast.AnnAssign | ast.AugAssign, +) -> tuple[ast.expr, ...]: + """Normalize assignment node targets for lifecycle-policy inspection.""" + + if isinstance(node, ast.Assign): + return tuple(node.targets) + return (node.target,) diff --git a/tests/test_regional_lora_hooks.py b/tests/test_regional_lora_hooks.py index 4b64a10..87956c6 100644 --- a/tests/test_regional_lora_hooks.py +++ b/tests/test_regional_lora_hooks.py @@ -19,13 +19,14 @@ from simple_syrup.runtime.regional_lora_hooks import prepare_regional_lora_clip class _FakeClip: """Record whether regional preparation clones the text encoder.""" - def __init__(self) -> None: + def __init__(self, parent_patcher: object | None = None) -> None: """Create a clip and its minimal patcher collaboration.""" self.cond_stage_model = object() self.registrations: list[tuple[Any, Any]] = [] self.patcher = SimpleNamespace( forced_hooks=None, + parent=parent_patcher, register_all_hook_patches=self._register_hooks, ) self.use_clip_schedule = False @@ -35,7 +36,7 @@ class _FakeClip: """Return a distinct clip while recording clone policy.""" self.clone_calls.append(disable_dynamic) - clone = _FakeClip() + clone = _FakeClip(parent_patcher=self.patcher) clone.clone_calls = self.clone_calls clone.registrations = self.registrations clone.patcher.register_all_hook_patches = clone._register_hooks From 60da3c1a48664ec8ed7f77aac4b6d893446d1cb9 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sun, 9 Aug 2026 01:21:19 -0400 Subject: [PATCH 38/40] docs(readme): update project overview --- README.md | 252 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 133 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 3fd7650..4447161 100644 --- a/README.md +++ b/README.md @@ -1,192 +1,198 @@ # SimpleSyrup -[![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL--3.0--or--later-blue.svg)](LICENSE) [![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/) +[![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) -**SimpleSyrup** is a ComfyUI node pack that grew out of moving my A1111/WebUI image workflows into ComfyUI graphs. +**SimpleSyrup** is a ComfyUI node pack that grew out of moving my A1111/WebUI image workflows into ComfyUI. -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. +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. -SimpleSyrup pulls from a few different places: +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. -- 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. +[**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, and guide workflows that can generate conditioning from WD14 or a configured external vision LLM. -- 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. -- Normal and tiled VAE encode/decode option nodes with spatial and temporal tiling controls. -- Expandable SEGS and conditioning batch helpers for larger regional graphs. -- 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, and FLUX.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 sampler and scheduler extras including A1111 Euler ancestral behavior, AYS, GITS, `automatic_a1111`, and beta57. -## 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. -- **Batch SEGS** combines multiple SEGS inputs into one ordered SEGS payload. -- **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]`. Separators can carry an organizational name, such as `[SEP|Sky]`; the name is discarded during parsing, and prompt-to-SEG matching remains positional. +The FLUX loaders only download those revision-locked, checksum-pinned support files. You still install and select the diffusion model. They also expose manual component selection, diffusion weight precision, and text-encoder device placement. Moving text encoding to the CPU can save VRAM, although it will take longer. -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. +## Large images and high-resolution edits -- **Encode Prompt Batch** splits prompt text with `[SEP]` or `[SEP|name]` 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. -- **Batch Region Conditioning** combines normal `CONDITIONING` values and existing `CONDITIONING_BATCH` values into one ordered regional batch. -- **Encode Prompt Batch w/ Prompt Control** is exported when Prompt Control is installed. It keeps the `[SEP]` and `[SEP|name]` batching workflow while letting Prompt Control handle prompt scheduling and encoding. -- **Schedule & Encode Prompts** is also exported when Prompt Control is installed. It schedules Prompt Control LoRA tags, encodes positive and negative prompts, and returns normal conditioning or SimpleSyrup batches depending on whether `[SEP]` or `[SEP|name]` is used. +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, and Guide +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. -**Tile & Tag SEGS** is for workflows where tile regions should carry their own generated prompt guidance. +### 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. +**KSampler (Contextual Diffusion)** is an original sampling method I developed for editing and refining oversized latent canvases. -That is the kind of thing that is easy to do once by hand and annoying to keep correct in a real graph. +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. -- **Tag SEGS w/ WD14** tags existing SEGS crops with a connected WD14 tagger and returns conditioning aligned to the original SEGS order. -- **Tag SEGS w/ External LLM** sends each SEG crop to a configured vision-capable external LLM, formats the returned tags, and returns aligned conditioning for detail or regional workflows. +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. -### External LLM Prompting +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: -**External LLM Prompt** sends system and user prompts to a configured OpenAI-compatible provider and returns the assistant response as text. It can also include the first image from an optional image input when the selected provider model supports vision. +`prediction = local + scheduled_weight × (global_upsampled − local_low_frequency)` -The external LLM nodes use the endpoint and API key configured in SimpleSyrup settings. +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. -### Tiled Sampling +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. -**KSampler (Tiled Diffusion)** is a KSampler-style node with selectable **MultiDiffusion** and **Mixture of Diffusers** modes. +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. -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. +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. -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. +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. -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. +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. -### Diffusion Loaders +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. -**Simple Load Checkpoint** is meant to feel familiar if you come from WebUI, where the common generation controls live near the model selection. +### Tiled Diffusion -- **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. +**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. -### Model and Detector Loaders +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. -These nodes load the models used by detection, segmentation, tagging, matting, and compatibility workflows. +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. -- **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. +## SEGS, detailers, and regional prompts -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. +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. -### Sampler and Scheduler Extras +**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. -**KSampler (Extras)** keeps the normal Comfy sampler shape, but adds sampler and scheduler behavior I wanted available without dragging in a separate sampler stack. +**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. -It includes: +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. -- `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. +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. -The node still uses Comfy-style seed handling, partial denoise behavior, progress callbacks, and normal positive/negative conditioning inputs. +**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. -### Image, Prompt, and Latent Utilities +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. -These nodes handle the smaller jobs that show up all over image workflows. +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. -- **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. -- **VAE Encode (Options)** and **VAE Decode (Options)** wrap ComfyUI's normal and tiled VAE paths behind one explicit tiling toggle, with spatial and temporal tile controls available when tiling is enabled. -- **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. +## Tagging images and regions -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. +**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. -## Settings +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 adds ComfyUI settings for model visibility and external LLM access: +## Images, masks, latents, and sampler extras -- **SimpleSyrup: Show downloadable models in loader dropdowns** controls whether known downloadable SAM, GroundingDINO, ViTMatte, and WD14 models appear before they are installed locally. -- **SimpleSyrup: External LLM endpoint** stores the OpenAI-compatible base URL used by external LLM prompt nodes. -- **SimpleSyrup: External LLM API key** stores the API key for that endpoint in OS credential storage. +**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. -When downloadable models are shown, supported loaders list known model choices even if the files are not installed yet. When they are hidden, those dropdowns only show models SimpleSyrup can verify locally. Anima's automatic Qwen text encoder and VAE resolution is handled by the Anima loader itself. +**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. -## License & Acknowledgements +**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. -**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. +**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. -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. +**KSampler (Extras)** adds the A1111/k-diffusion-style `euler_a_a1111` sampler, AYS SD1 and SDXL schedules, GITS, the `automatic_a1111` scheduler, and a local implementation of the RES4LYF beta57 preset. It keeps Comfy's regular seed handling, partial denoise behavior, progress callbacks, and conditioning inputs. + +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, and FLUX.2 support components are resolved by their own loaders and use checksum-pinned automatic choices. + +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. 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) is the source of the beta57 scheduler preset reimplemented here. -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, @@ -206,9 +212,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!! ⭐ From 2dc1331262067e2e4696d5ab11d5b4d22aaf0cdd Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Sun, 9 Aug 2026 01:39:24 -0400 Subject: [PATCH 39/40] fix(cache): make integer narrowing checker-independent --- simple_syrup/runtime/auto_model_cache.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/simple_syrup/runtime/auto_model_cache.py b/simple_syrup/runtime/auto_model_cache.py index ae88fc9..ad0fbb2 100644 --- a/simple_syrup/runtime/auto_model_cache.py +++ b/simple_syrup/runtime/auto_model_cache.py @@ -222,7 +222,7 @@ def _optional_nonnegative_integer( ) -> int | None: """Return one optional nonnegative integer cache field.""" - value = payload.get(field_name) + value: object = payload.get(field_name) if value is None: return None if isinstance(value, bool) or not isinstance(value, int) or value < 0: @@ -230,7 +230,7 @@ def _optional_nonnegative_integer( f"Auto model cache entry '{cache_id}' field '{field_name}' " "must be a nonnegative integer." ) - return cast(int, value) + return value def _user_directory(folder_paths: ModuleType) -> Path: From e9cd06e41c0018b800232a9b74c873f75e8b3770 Mon Sep 17 00:00:00 2001 From: Daisy Date: Sun, 9 Aug 2026 05:44:42 +0000 Subject: [PATCH 40/40] 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)) --- CHANGELOG.md | 24 ++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- pyproject.toml | 2 +- simple_syrup/__init__.py | 2 +- 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 609941d..61d9f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +# [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)) + # [1.5.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.4.0...v1.5.0) (2026-07-14) diff --git a/package-lock.json b/package-lock.json index af4fc02..3b97be9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-syrup-comfyui", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-syrup-comfyui", - "version": "1.5.0", + "version": "1.6.0", "license": "AGPL-3.0-or-later", "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/package.json b/package.json index f89952b..93cbb40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-syrup-comfyui", - "version": "1.5.0", + "version": "1.6.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index 6527fa4..445bda9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "SimpleSyrup" description = "Workflow-focused ComfyUI extensions for image generation." -version = "1.5.0" +version = "1.6.0" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] requires-python = ">=3.11" diff --git a/simple_syrup/__init__.py b/simple_syrup/__init__.py index 2ad1832..16b5d98 100644 --- a/simple_syrup/__init__.py +++ b/simple_syrup/__init__.py @@ -6,6 +6,6 @@ from __future__ import annotations -__version__ = "1.5.0" +__version__ = "1.6.0" __all__: list[str] = ["__version__"]

L z_|%Z3o1p{ro4NO{TzFw}al?vNKT-3vKYF`wh1P$n0CRmV11lG%9$eh8n%!0ZFHTg`s0KhDuxtNT_+crc;Ko>%srBphrI%|G9HkYY$7R>OS^BFBY-X48-UXvEi2wC= zo?eE3kTc^te2u!zMm}#Z6#<79mcHmzLEzWoN4)U?S^|9uV@rQmF1iGErX!fj;-`g9 z5SP)?K>&RtjgTr|c5`#Uvtj{_tu0Qi@hGNP9Qw9e`D$1Nd>izbu>|WS63BM+7c+fA z?aOXtXkg)=x)jtd05e|36X-{P2{31tUO25G_IRx-uS}4VtC}yw+cS^`Ic_NCyXGe;bF0e1Nsn=h&txqO|YO$ossv zBjD^UmD#}QuCQg781s8Id#;w zRZA$Hss~IA=-SH*KPbm_a?Umwzm2OdmIc-J2;*%Meu|2`XRmfTzGcN zGI7wrYS5NBa0?98%T&<+pdfiKaIH6gz3x0joMr8Xeh)5=E*SZghd`|PkJLEZIT)@t z7iQ&Z*X704eKb2Z$sq5oI~2=JfZ!Z)|Fg_&=fn0!_w8>FC3=4;H!6*|tsI$q?R)qS zjBMUtSabKYn+t|rb0=o&-Y6e^7m+pm6ZJS~l>8C~EezA$tbaUf_BOa*0%#R)KGyC0 z_ArO>h|>3V?&2qP!)IwtjK@D3PjIZTgP^Re*^lk_o1qW}}u|6+;tJlYE-W%@uGWQK3h15U%d*)|Au0# zw4qSD;VLFS@a^6Hdab~D2WH?MAUgMT@k{;Xl%gd7KN|S!JLSIaeCp%>6Xkw8n)?bQ zAsj#lb-r%#0{Px=1bG&s~ zr|Pu*L(b-Mj|m-3+BJE`P^`UbE?aDH?%KX#=})@z`F;`sHatW|SsyUyyZ_m}^@@U@ z4OTGOyZc2k!}&TnZ!%~f&XVWDlU{r>JN2l*sk)?Z@Zr|{EnVdgxliAv`mEOEd*3g0 zeZx6YY519J!l=}?`HoYH{I3~e{Wn}G6!*eL-=mRVq}z9T%xLJKa7id1$BYN`Ig=p6 zUFCLPO(l-rCA!B^WwaFVwnx?K(!#fHyxG|={y5@R&szOC3fZn|0;4<8DszJPgg&Gv zyNwl|WLwmAD}C#++3bCuy#kopt6oLjefoXWkLKsf9z9pP=r4WVu7t%hhvM*GjS9IM!>kq78 zCZvQIw0`^mPAYd!r_Uv_4g@^jvGPi9;V?mmfti}!4doz!vP5;a_8en{WIlGx1S9mx zB2r(?t#>9tTIP&;U&^R3CFeq+$f^T-!+_YGnd_+;epz{^wfmLR19&K8^ER-)D6$MY z>{{xQdFa7MQd;sa2`E+pHOK*r&F(-fc7=c_&TmcH)qS#(vL-xgIl_PkPfQD(c?8T% zQB1nd+oZEGR@jxe2AE1^sc_X8nP3e>=TwEmPEaXSRqUxy9{CM%Bp`Gv#ywpLSsAB1 z-w5_eF^7HSnKqd2mG}-qTEabZkRexU#wku{i3SJgF>SOr+5*OfxnEljevp=wWLYX{ zE0_lR`co7$yAttm2gHdL0s@r6G>MdaqTWD@wuCf($6NjnXFc4qDMA~9MjN0CsW?g^ zA-nJ%2{1R=#l%)XKyK^H@x%q`;glBcCCCZ9Xu&+w7m5)912<26n6`m&B@3MvI@5ny zt-osqCnFUxvZ=(u)iX6P$BRdxGHZiPt_5dgmE{Ho#uJ!Q&29$#P3Hz1%@}mkkYe@} zhep(fY{j%=zyc095!_}fp%meXtl$I>P_BMixjki+Q*PEB1O*dHXqskpyQHMc>}jkf zrvnz?x~v2eEFPU=GaAOp38Y7oQiAm<3Fa$-sZ=QmB#6X-Fs|r@7xnN{8aHE4OmQ{R zA|E5S^d$MM2?DX5KEd}jbegzRrS#<_B@dv+O@Qk3I4B1M z0?_eNU6dvL;1OUB1As|R5T1HZ_5z_@Nxi>qmbYH-9a@jE!XAf6#37jv3w<*2%CV=C zTdjOB<>B{U7c(+nk>la`32MCdX{Z#)ur7G{XzfaY{1`jbhNnh{`nqXq}SRNQRIIOq{l6B>$Q?4d9Xw zo;VFeSEZQ_4M0eyg-(c*q#mYP-U8M`F9Rqq6l6;TUUVux0~Vs8>BQ8)6f{N%YneMp zrd-}1VT_|GGG3|nu-p~9JVHp6%GuMIZ5a5$*U!H~QM^sS?ov@jeX3 z7}l1smskc(pN361+hYUR6N)a(J}nMRm(KKyuufJ8KKEY`3heg1HmML8Wk@!NDQ#xX zXA#KvfKdi{(o5ke$YKK_7j!vS1=gb{V;L4^*z-~EOnejxdaN&|EW=o52d|6Xe!+8Y zH9Gz1wpg3%i2Jr(w>X{)z%s%`|1;yf?4u4gBym_M3FJ=sE=^@)x_lNH#T_UYOw$C zr3A9FL~!1Qo z8Ftw(_lKVJq;o3G^Z{eKSF)IR1HIP`zuT73?D?9H#v}l957G=PX{FG%gMf``>)Oi!tvy|d zz|mGJz+im8CK^_AE(m-20?lTc(zaxYR5ML91XW9}1ZWdsXE^8R4)0?TT{OBed8iYoK&juO_d3y$QupCBJ%Yc0 z)2caAt~yod(&aosMfTP*R9LF1!Vw(ti zZ3$s3N!zx}UTiwNq6Aiw&W`YLy^e!iVTR51bKHOd&-OWdKM6{QJ0cRZ;Eu^X&o$Ll zTR1@ulc6&lN04L`8oM;YVG*60&S}zsNs($J0GV=O(;!}m0LyH3 z4D&37nv6H<2>B`hWx6vwUIP!kE+t65;dlEAQa@F-2I{WWWg1*KKK5a~1HzW;{X1I< zQt8>2%DF{aI_;)1nz03UcJhQgxf!p&rak}5rQ)c7s|(_swP{Tn7q6~AQ5Enp{AOv= z!)lHGYrE!(x6z!5nVd5!M(f{fpZRh3$8@B!c=<(hApP*=*7VVw=Sfp~6`!BqvKiJh zNaM^2+VV6$c16iGd)oVKGsc${wLkK8)~W2C${@WCml^B*C_2+)(WWR&61IF&I)5s` z9@vPFKRxEar;&&6#2$@ss~CC@KC5QFnAvpGg|&rjldBpaJf(k^+&Cr2eI@4PRAbt` z=d{j=14Gi0^FFt@_Phv-zxFPoo9lLH&dJ)(M)w7V^rmbhuUKYpQLRzsJ@>NjaK_CF zNh+;h6;p8Dgox9RIXM?;e&ffk$&Od^&($rS%cL}t&nWv2U9@)GC`H(QMepg<4S$c} zPn7e4lgI6b=()u!RR)GG+$qOzS&-Uys>pXf?ZZ+Vgxy}WMPa?KxlxUcOK2B!cT&k1p&Pu++X;_8oISulCKq)5RqdydilhhEd#~*A zyZY89U6rDK=!rE7)t`FXhkk73=Y3VL9O%)Vg2PsCd;`+6c2{e0Z_VZjeh@`$8NeSG zTN$Un`)kAT=aGAEM7+BCygaK$rl#^%CdJ92r^T-(+f7qV!}!BK4Ch)22jAN!t~b=} zWFEYdzFl?bXpr)puFXTce#P8Bn#OZUW;|SlGW;)T?<AlbjUe=Xj@@9qfF5KC#q}nApi;k z{jyu-3Ex+QY%j7pr}sxvxmiY!p3fQS?>2$i7fkUJtvs4->w5xTR6MLXc$^XL>3Z%; zKu}S&b5Tv*&DC3F)`z@2Z*z$A%CUu`k{fI9uZ-0F%4L*S=N+u}TWvL_6V(=VJXE%? zp~Cl|kD~qYzUl#1iM@{d(??s(4tYGVR6tFBm)P@%DX>hq+0T$`GWzKh`HOkElUtSK z(bjR&>qB`L8AjYU0$J4&G`;yR1=}gZL)-UX310Eym67s|N^kt|(S!%okGb6xPM_xo z(qGOc6&PoRx#T=pxOGHJ9ILAt*F4(bL*T4?#S&tD3aNFj5|?~WX%>q>DbjRfTjQojbo(OT zDe0-cl1^cifS|8>Zf}fiexBd(o=Ca6ECpZ%v)&sXJ;rwXcMHy5;qCX+KOK1O_uFx| zt~h<}+1=u(1|=zjRxDdR^2FC_8UO=~K*-K)+#zz6P$AG1wXK@gzZj^EQ6VB~xt`A0 zQ_Ug3BYHVS0%7xww3W|JyJl{DpF)B$@0*@?;+6rU>5%)zjdI5MrF6)_M+Q(Kq^;3x zvP>Koh3g{4(t1LI6D?wR;dUX|*Vv8aeUNrju$MCj)iTaK9PmC*s+TSHHhC^6@yiaGEoL`?qjIKpm$IXIG( z=bDb0<`_xl+XFvQ)x+08&*N(U*CVovlz(AXf}lzd^Rb(SW}IXRs-}~wBUD;0AorL^ z#bT`UbaZMT3-j;AB3`G!#3GDAh>jLf)#2PA-l18FO6XJ&k1FG|+#sy6msp~pWdZnl zl6FV}kR8xq+ZeO4wbnX~mo0?qw`%BI$c{HXfGOa#oT;xd%iAgd&&q=R4zjmo-m1_9 zK}{8brB=KEx=$q0!Gan@V5?!o2F3mnMN9@PNdZY6-N7xM?jpH|t4%K~1{?bntu)m6 zatG+cQL9Yv_`oS5$gIPeA;_%b&~4WAuo%&?OOqO@LoMsv;RVj>C}M1u^j5G5#g*<3t^8%h(wZu}RF8meTia_UC-vGs4?OQ#IMu5~(t6g;AhbJxywrj+ZGsK|; z5_?v*g^aNsjM~8qDTFW{GLk?_fME)Bqb4{Lw(4+Mf`s7-9SiPy4+;R-w}!t~w*zU&TpnpkgEXe`6Y9kT98s4bU6a(t=vI=b{!>-}bNhzwn1P~2#bl>cc(Z&PDV z#K$KI)K*$|m}7!{1!~(;wdCcDx$^nbN)qw>B&yGGQ8~H}H84#M$}vG`HI)K)V5vd~ zwieoo^55lGl$6(Lr5XH6kX}h#g=x91y1-iJtdSU*9dA^>Y(Bi0JaU4^&i_AgD;LbZ zDloa4Vbl6Td=p9OlIRGF<_TdMv}<%JlF@{A2q?>la^qx^@4Dp%zNCjQDkQpNjT4Mh z^pa#JimO*+XaLH{^Rr#OXfS1xgOVd#1d=VR1Gs{HOH3?(%k@%05=@8yFh>-^A_0RX zc6ZjJ#wEQ&;*cU#{O(9r9z-a6CS0lkn|!AMlE%Bh0zeqy*~7j@+^cZ?aps6b;EtuL zDUMpdg%Y5!E&!9EWLOuhL^4JzA!)G14G{t)q(`h4PLU!#$oghw?C_wHmjX4GK-RvE zBh`>o!-VodvLzJibtG?NdLw!FlQ=P|U_@N6y_APs$IQJL4mi;SB-+^`zyii>A>aNZ zI|>P)B)F>A%BJ46AlZ`WK1Bw`yhug~nRpG;D}2ZwwWtmLO?rh=OlDF0C&W8+A(zhu zr{zWAg;y@<>;x}yq@dLmI4l2eGb^HFb#!j=#RbgrqWJPXtFID3y?W`iW_~?ev#J2& z>$I&f*SaCR`4SmpM_~E?CS2FuVcUAnHAosBkh`JQNt(dmk}NGg=71>y$%&p7z_?ff zldaDRaOaJWNv7};ff6(w!8Bw+v!0Znvcv^dsFtou4QZ(hU9B}{nL z*$0Xdjo5cHU(S4(-n?gIn>uN7lg-A&dmpC{GM;{+UhcjB&~N2%uO2gBFoHTIV}nI2 zzYE>C_k5wDGhc(OrWI4BMEk%e>GW?33qEi596Mb7bt3EH>GsFwpF-1Thi~3KXzR4%GM>O&b}WZ=6@px-xP?UWc+$Z*Tap zrc3=F0W%5pK{EE6f^RCMlfQ2t>3M!XC%5i`@sPfG+Jff@bI0+Hr}k>)&(6|0KY9F! z->Efy3imAG_?|Hl%KZ6y_v{;g=+fQm&V?tQIwwPSdTvC|PE`HsXqE*m#PnF@Cwy>K z^M%hFb&MFnzO_~RxluRB-`BaOeoC4xp&W}{SDhN^2RO{9_JMcFERz!3dcBN?=V=^4 zCF9l}WWI&FfkTW=`ka*J(cQlNVOpQE1Zlzs8KEyXr6^@TX(juqX%b$p!fiTL-8ZkC zp(k)LL+>SH@{g~4QriP3b902l$1%G7(%xFrGu^cFJehmqp8sBP;o1G8Hjm7`FI{Jr zzIy>oapo@^c_@-2Ftz5eFIBd-=!8VsjO?Q<7w5Ns+)9}ve|b$6Hg8o{Otw(SC7gX^ zbiBJhDg5j^-?5|W(#72J=9b5bQ}mq!CT*OCZdj$Mo-y3t^>wBR*cj(BketE9t^1gh z`{vq6QaioTZ{yYQyuB(Ehb*Uf0;wuXtS?F-pJT=9hS(doxpUjya4GZ@1-ovpKbbc# zhDtZy|Gj7KPQdfrs*1;M8aw=sVXxE7YV$c=h$xPa`?w_6)4z!xj2&i7_{o%|MDCzW zx+h(J#9$7U^}e8gvGJF2$}Z`bd&FmgzM1O!a*wd&`r!!8Hz(^p8+B&ubyvM|6?L!n z)IFB9_f6qa7VZ(#a*&&oXwLqC8m#HZArZ*nZV|CO(0ALUfg#yQ?s@_cSjR_`Rx zFzfF$ogxBzmsmIVgzdxnIG$4fiF&d6M!`?i?13jQE1y66cIUIsiSHEhWPYl!G4E+P z^f`>Zu?5*_V-5N>kLH5@Pt+$Pe>tBAD8r^TEd@6j+h=8IT{3+Xo9FIl{Yt(}?H`J) z)840h+WUH*hp!SbXt&;j2kXP1tb3P6|6@Soj7@k2;l)ZKP?aK;dI-ZU++tkXxmhvHu5Db()58BXUQSqTDgV!~)YyMZqfC&D<|iLr-lUTAur3i!1rmd0`J5 zvI54e>Soe|2ibh#<#Dn$*LL^}#gACl>g7%Go>eFcOD3J>&>e7|Vp~zY#OLr@0qnOT z$87Vp&LvO7%6~VuEd3EOq^}sIJiS|+q>joINo(=5<*aEU4y$YZ?ytesW25XKB${;2 z;`AehB4PP3QZTxWQ$F}`L6jgtLWI_iTZI=?c637>XBORFv#&_etZ(_~QsiALJ0oOZ%TRissgB!H1nwrk-wAD5VsZN3rhtDV&gReR%0<;v?y zD;bUE=<(6cC)G%FO<&~ zwU>-9mM2*uVkJr})mkWTJPJiq(oCv>&{K{&B|M4~7b?}^khY2+CGXACDUO~18*)WJ=V+DkbY9-8N1XNCDD~hKxK^0~-1sFzDcB4ueh#0$k zYhmtPBwwy(5srh06<{KPc?W(#!?Vo6#t~@+a!+Y^%>k+^h!yKjPN`m?Mf{5`3NzV( zN7-0=I-#ufTk>_~#L*g4*tq@}u#w#>>VdhFBdK7&(wD1eTfrGYsAMUCL6B(pAd?Te z`ox0B{{SSXs6AZGJZ~;xV4SIxa7-(Ul$viHD1(U$Q1oDkB6qb-!1cVkKGJ$-9u+LhKp&Uk>e5D!?ZWP%( za~f>GvyauTfUC=P!TeGNMIT$QUx5pWSwM1gKdvVPh$mnufq*^d1F0MDR1dYk@3d_n?K9taZLnY(P5Tx!w3P$PrXiH0JP zb6B}J6tc0UiDcp_(AwgK(u%jCgyH*C%P_zO3Vjz-6g>nXu zmo;N0N5>?isBET@V^hvGl(;`F<^V?$%2eWR5A2oKrGn(5UV3=t&DBj6>~N)EWPt&r z(eB5o%^S07ThbWzA<~+*zfa8UE^kILrFj(Ob#|Mz*W@OZ9Ho@pr4OI$YLVXjd7RdL z`Te%BhZbve;2sS*i#B8FvZZidgwRaAE`D(?jb#puY zlzcb5t=MwhM%&P=U-Pz4&;)tS8sLQ!7`?UpK||nYn$WJHm`HsJ|DhvYI<<;;AUu%J^57QG(4HfzEZjt zDN?pnveEn?`%CZRalSY?TX0LSB=_jZ?L_?_HVuXo2Tng`Up!!pyH~pKMXu}CwC#+w zHKLQB=kJ(~m^xjvJ-l(&M0|xXS~2PBddC`vFqe<24v|Nnj9VqWZ|ANyHRW=>{lb(i zB!u4gV7Fk)54WGFud;EpfqeGyykwyUm+Q%P+p(|Man+fg41$2%&}Wz;|Z6XpPw3TsHlE zY{@rifHn}1F~yjYOrbwqx4kfW-mB)CbFK&tHF@;c#INDt;LsHnWVGPmUclhs5O-c70l%SJsy~5) zqkvPCk<{@tImkwa;?3N4tsZvNC0j8@;uaI^QbFF;VW!Y96;PE$Xx8=0eC~OWjOh!0 zmzW!2fueLkTOfxigIR>C^#Z@R;rmg2$MKW+s>}5VEIZr#`s!XR>|i9r1q6e-9A{^2 zcWy(=$VAngSeTjV!ch3Mw7DUxSt>;dDKn{&k!muFw%lml9K;0$1<6}|eR5VryUxb# zRdSES+$UV4%2~F!&(w7b3>Y>;p!0aNZpc{nzBH{E|HGQOR5Zf_{c86O`^ajyCJQXx z>~k*>PQ(|14}@XnndD_8%c((nskXftEA}IhSUr5wkmV(T7u_6&zj_RkysXnPgL{d( zU!VXNDue=`IM535EhLx+s#3RvG)i~Z6rf;BqHfQZr?;+tGUatY9=L}%e!Pm}4nx6y z2jxuo`^h|gpijw@Xpl?2H^bB4$veC*@+A!{3Hzder~MhUQ}!oHUiyQlDTik?H=PY|@jsUkilUUz4@9S4 z&Q?RhKuHOpx3+y8Vzf%Wzu$k-SEbVdzR4j87sXA7{r3}Ew}iLt1dipR6{}hgu%M?9 zARcdkJET(t{wFdbKWsyRKL-8({}=>BBJ@q9K(|U0%eM?nSymV^3CW538>9dG$<%>` zapWTc_kIg0%HTDCa!R89=Rr8oa8~{5$9mVYM@FZ74dakj{f~m%pF(Yal(q4~|Ab zCjB{P2TyAy9nR0M9&;aQ9tdKA8wKOHuYVj$W=u{t7t<|4w>gnmxu&^kIw@yh(QEJ0l!3DW5Mw7L_rWqkKk22HD)_<+isXo`k@~A+e;xrFhJp*barP$^vHk(CYWCiESRoYNW#M zI+HZ=v2XKxqX^m+lQj^i?5++Lcz`2{|8skoy$g4{8efd-d96EqcZ7RPXOi6SLNk-k zA@j)pAlMt}LtnJ55*Ru0&mes+Za*6f3oe$aUhOI0naHX7>z^$v-cc~YZD`3`8ChA` zivj951}??fg@@bo-MW4<`ys0sT#N4tISYe@zybYNB2{jvXhNVQc|p}gICo!Vv)8&f zq6->XVUDd^Zftp=W3A%)9wLn}c(Zb-OY2U#?S5J3SE_ z7pMKljJ6;8bCf~Q34JI#%*gq)H%&W@t#n+OU9_}F@Q0dl>{~B84ldQ$@*uN zKY^{!->3;%o3~2pB}yn4Xn#1XEOS_LzZhbE)|ia%Qq5}C(b7_h!tbalQqLC3W@2V` zz5akKypGl%8x!Lq%ofG%g9U;>?hc&-l$|#03H2mZ8KJD+AeQVXS|s{Cg^Zp zlvR)4EnfJ~ZuuKW`DslM#jm!H-o{XKqK5|;vdcr^!1av{+xshWoBOM3)!I*Pwr|CJ zaf^$JG`ze74;P!dRdMr~Ez;PGd+jHx?JoE53ihcAs>iFfjF1LsTpQjuSf{*8yS1~k z+xZ^!2-JnP5-6LczWm44ui)$fjK=$yTtDqk*W;3Fpk+~r$X5|N^}r&nH2I)Yy86bd zM$I2TYQBk2Yw53kTc0z|+Mu56Z{edjIEcTFwqI6Ct1MO+fkUt9?H#5N-k5Yh9m(x_ z`mkN~f?zVnmQG88NQux88>Xl5x{4h6nrM?xOQje^I)!%;N5b-5 zD!VD$qT9pGX~8~av$UP0iDanRNL`I_yH=O~Bd+9UXW+s6SQAm!Q6;7aH#R)uB}0(V zu^mEP6(|m$D6c5eoLyYpdWkfms0M_g`@t_!cL=&}Uiv*hUQ3yncPS(Vt40^O!)YlwxGV7 z&!EtZT(WK56fePqhp5k$>r(hfkB;kjQi`DyF$t32-}oGVDFoaOo4!@%{=WY2@<->Z zX+6DJlc|!Pp8{H&c9_~P<~eRO%25n5`xFF*|oe1O)Uj6fB#)L$#pB zBuy~3o1gFx{dE=A&_-LWBFUo%08(5mfO1>iP(q7sxZ$(bzG%?-r*;9wkDG60y2){v(n5v2fuax1!83A_k!A9c*gApU9aC=?x(l`0jG10(2G$=B>ws76DSM? z2>?iIu{k0x*nTB`ejE^fApUuJ#AExH=$oqBFk=QWu^?baqhe8Cvr`vY z5-7^ZQ&ylZZSg74C@LvcK(2ne=cTxRxBJM|gd0K_EEDqTXXj#*J$oqbRgjBh8%wx* zB3#q&$x3>Iw4JaB7d?;rNXPS_mkdONd4QZIZ_5Rs`3JpPs;SBqB z)7TSi?!6fc->=nriQ<>vU`dA7Bx*Gd97V*-?U1zwJo{r|6~|I(dmzfVrbW{sGN87R z+R=uY=qp4diyAJ_BxvdZcbg*>@zi*$ULwt@bd@WQ8 zBwykHc`F<K@_p$0jV>#hPNn=>#9Ogv$bC%T=%K?9!^Gzi^cU zZNsT?Qsw7awE4F`{NV5QUqOu`lA-!Xx zt#fokbhHX3WN*(dWq?VLo0f)@Y#ct8@|wL}O{xdZH7P(eJ9_3vLnP`4DI5}^2>e98Aq*DJLJv1xy39(^^}9mA=2&$&9p5si-z zQlT-Y8_xtY@-+CWQO!oze74?wm$o!BJk(|kMq(^DG<;RGlX#}2{>x^HqRWzBit*=o zw$uLXs3XGb9k;HYCk}u7f?0~dqW6i*lrs}rJ8JZ4R{S!TPgeR`9X3J{ZSCdWQg9iu z&XQ__LK+?zDcj|u&CP0oxbGowhyqRrB-?BgKG}xQd?UKO7t3t4i)RBuy`5V$Z~|<7 z7SfiaRB%lRj_wi*2$ZnRygRdr0$C|4NvN4wfLoeWH4Wk-kHXtmOW|bR3LnE+qF145gOV&%J??IF_&ATMhG|7SoT+8(qI>rw0s z)YQ_is9!O0gXy-21HUFx!5B!LQ6^lUAGXD1iTk8OT$B38am*xGe^_Q0))W;@`efhn zIxip0OT-e;Q9l-`gyhLAXSn$#J0MJ;#t`DpB1CVd6{v6GB|E?= zy5hNk-k7(}P)EoPnSltc)hxDI|WBupLg7kOcN<6e0~rH!yg9z^~*$)G|8qBgn8spXwJdUI?m>_1wxR-YyOeo^4P}2 z?Gn{+i1$(Jc5>U~YerFcq(*ENVe9mj{l?llA-?!CW!CyHW2jltI2i=gW}M)BI_UZTMFg6AV^Lm%Ve zfSTFVd4`7hMm*8nITxojOQ&bF(Ppk=zurxd%fP*FsGKh?r3! z$hnMr$P}4xmKA3%LkD<9tSeG2=i~Iygy6S@Y$q)#v1dHn)aln%5I95NuVZMEj48|l zy)EESHVq{ugO2#xCG0X%u=-Eju7#%YsMdY`-irEqeY_NK(aU?AJ}xQuCCjl(4Fhv*%|ZepU-wKIsdy#7rw zb5YOSOVXAfbOZhvWqkSDH4$3ybP|18($)46ZEW*LD6&5&bYLVCHF~Gj_Fu zq9tP+#IQGCmjparUi5bc=hsWj%)`r;iR@pP!r6nqh47qT3RVP(Nb}?+Cm_E$C_V0U zSvkbIc@Mo^-Z4XkadrHhzkmG)#98l zGY%SL%x=o4u`b9c+Psl)kXv}d+HQJIvtz&Lo4;LJ)Bc$oSp;|3De`|9B*aGkTZv=q z6Fa@MmlriBp$zWVl*@ba= zR{xjA_*-7rRl$6&I~u1Rk9U`oKbM?n0=B`P_%w~aUbf?LH!F>DC zDx10<(_v-$0N|MV?rpmC58qSQsrppUbjAW`t`R64R9H@t|3-K}!&Bq@Mj;MD7-9LL z36LpubTN(l#c&sBQrp8&bmm{astBNUUaDmxnKCP4Sm8Qw5IB|DuOfXbMVmq-gg> zUlX~NKpNyeDE4!xoc#EQDf&0L_LnQU-$CJz(7aX*pr#>;q#8y9(G`xzmk^<<#%K!t zLva0jiTI$u{1EV~eucV-3%%C@X#(~+nhl=?&XMTO(9<105ca(VVml6Wk`XfhI>^Ac zL*M9aWWiH||M1%WK}YM!G9m7kA^yKt6^6oEwV9Fr7H*}AF&ER8iQ4MIF2S+x*z70l ztHZ1yA>GaY6EYPW_$Dh{QKXC)1~kq5R<<8cI>jc%y}9MKQ1#xwsj(7mQZXl7?f$!3 z3Yh!x3Q^Ljyb)%LfVjXyW!McFI{ zp1H7PJPSn$kAEDlp{cXm5)ow#ap4%!(9me9sHoWBWt#g+`1z6Q>gzM_yEC$}+0AU! ztaP}Rt;KEU^EqVYh^uXe8(xOec7BWt3qv}+-s>`vMq!MwxIIKB(6q9p1vn$*3+}>z z-08BU6{`H1qP|{D=Z`G3eSKPq4+J>WPbKuFkgfDSBqcdR6*St4GkSnpQ~Fr-uD|Od z^o3Q|4+Se@Qr!(xKT+M8Ic+y-MBTy5Hlc*D0A4pY9ZjHPn_p?H#=gy(w4c5`ZK;VJ z+TZi_A5t7FUnfk#v&#Hn;I}3muxd$9J8>)@Q!v%g(98XJR6ymuc343SGEag+c|#~6A=o0qVRjXe7!8>xU@oxr1sV&0-h&coGm?CY(e!iYbCoSvVT(Bz!pIsBYuN!4+-l-66I4E4J5LdE@)i#5mh$kG|Z#q4RjGVoP#4__C>n#C&PuBY|S7ir7HQPlbqBMMR_A?6f;e0t;H(p(y2tJ6= zL&&?3Pn8wbc>D1mzfw(0{p97*Af++>Q24Ia-sTf4bw|FWLdV&nVcl5RyZ}>g=iBCR z?Q#EF6Mw;`s2JKrDGt8W*L(ZBS60krRW-9Ie`qtsz2$O$Fr41vJ}-LgP^|oLB!`Rg z|DHeaTQbCE<60atghd}aln{^=?}lk-Y7*`D)q#kve!RWazVj#VV*yi*SIt887q(PV zF&nNQl3H5Wy?xG&MBNH|dwb>e^;!lkvaZql=2E}t`5)a_d7klzvX1j<^Zj#+Jw|Qh zNIp-wE^EvZmC&NcTGztE8YDbBgdAN)mE}I)P@Zd=3^yz-n<{g zNZjZCbN9AlYBSqts=nT!vw1f+ErLMP0pcEF=_1=*+|#S|diKMHjG=`}bi61evfJBP zr3w<-y|0x?wYgqsoS?iW1$L;$v`cZjld4m|5YYEVoZ)So ztb0#AS@(;)TwOTE+o}l1ws3fT!`91ROtj8CDX~cY`RP%n=n`ObQX2E6$BG^v zKDhq=4!<{3N$<8ltNJi-H>EB}%ZaUJQ?T7qRv2TgL^!(=pAp?%1RVXup!S zMS{KM3Y~TOHED^qr7Cfb9(X5Jo@(1?BL-c0uHv$8v?;(xDG9xcA?@b4vPrcZL{721l#J%glFxXTcU+Cprisy%|K_DddV@3d+j&YiKzu^~}bw2l|4S zS9$$7W`b33qh#kq^XjV^jaxC2Z@;~GVJPL=Sea$(ibg9ZukHIW+Fo$BDTfe88Kv%q zgt}mz;=od@6-qewjU0=SfK*ix3?2dpqwD*%?cu$WoNavL6_#86S)<$e19g-AQmREo zh2d;~4lbU&{1k8Qv%2CWVz-NAGV8mOig!+PHKdPsb6rIMs*Y*%5RZ|IBU2>dcPyx= zup<`pB?O41oaT=UN&t-<1rvW3G3@e3)YYFGzd7C5*{|)&SIwf>dq3Ay_Qo3vWIh0J zpu6W|T{d8Af#Wx=*}cwBYa#3G$|>I`#Y&SHzSk9o`x;_8f_z+e<}Ypq*6(~df<7;G z6XlpJEjHR#vT)~18yaptIgfG3C8wq`4ZAb2h~%>f(7~R#GwF zP$aY+_5QfryyH$I`ZF{lE`?WL1D?)PO6JOYGO*Up^2x+}1z1fUGMr5$a*V=%v8_8j zts$;EF_>O-2f3@RAAF7LCHmdmEO&ovhJe}<&MndrW(B%8fo^TO78T0JG4xN@aKjXp zmGx6YQpBH5{ek$=7<+A{C}AbI|9D9-(Yl>eS#%phpX-~bCVa@#QqQp_Z)+P-n8d_j z1};ISBb5x5vbLrJ9yNVvXh?>~G&>y8g?jN756{Us+>gN8C2Bs(SZr-$#Q8bB87;Tg z^9W%o6w?;u4;YLyn4@u4l!ZM=`o49)YWz#|CNrOuZ0DfTrn76Yq#Ll_g6+VoCs8IP z6^KT`;m>ku3yZ`XBVAoaZ>qd~y3SI~zMC7*(+|k0r`fTWU%v|MO%HC7pAd-0{x)@Y z=M%l#*%)dyh-~;|-UGq9Sbct)tIpU&)Kx33SwEhTzV9_nWen;y#R6eFI=M9FV$gm0 zJE!(^na9VaoYqd z#gU2H++C2^K0OfZ3?z&vIC-qx3LAdAL0xPB#W}Ni=dsOIFPUYqi!SE~w4e6bx{ir@ zOeNAP@8tL#X->w(-y)+C<`p%Cu^Iss@;<=m=S8u#rT4{>{*8KRu7BoVy(fvTwQm`W z0E6EMnfGHo6MwwmzSyfoO>UG3bRK~UCZ)0({c-o4e(9L#^mPr1mi0XYBbW&m%RH=x zGULNkY5$V!3rrC7R#!(y!^9+qOWDF6;+Xl^64XD9_S$!u>{Rg-H}?P=#G5jsqixO# zM@0lAXantb+40hN!LXpPpv(cN;J-c3-f3zUD1#)eQYuNRU&i}lTI1oRBqt{`_;11~ zDh~c!GoO6x8%|HxX8pBS=xCI3lwyYO<2(y&&TU9_5^X<$3MLo(Rx#K2EW-J0Pi{^O z61FER{@!MeQXe{W`q1q!Nc%riF@^e#zh}YNV_EY4nmq4%^*J61otOcMST>E3#q`q; zuZtcW^$6cZH+3RjJDO^f4mksimt+ACn%?`>&*OhaSF`X5+**BaUl04b_b!k)eUmgW zsL9Y~@3@#O(RmIcKU)cSM)cmoo2#;GuojgeFcnFF3GK5y;{`+hjAqdnRZa;>cmWa{ zEzT8E*h#oJQRM zO}Jasbc+*tMR|Gta(hg1eOHeG!>YQ0F*6|T(5+KaM0LE33i|zX?l2)fQ34!MlR;cL zTq!e6^dY)-DFhrQP7Oyn#)X5;P_ruOS*2vBGwIbAT@-ck;YDk%)kMUx84RdqdHwNKZYni7k6Qs5D!jrWs3>o17! z!Bc&a4!;84qwUG(Z~_EqUn5D9@tE}{qlu<|^$=~E=8ARfZVo0Du#JrvZ&&l#XRFc)NPwz=oR^(QXXbht;pLz25sv+5Ph zGlnNSV{hHW?)J-jiwPQ_z7Btc%Xl+=`yDvqN|pKn%7t<<_yKk7C&vNLMh_{M^l=y( z+v;MsxPR3Tfj4{7to46JGN^~DYv?aq^K#_l$XHWjB%T075#bHTz#kg!n=K;mmZfaD zuvOPYIMY)5V|%TtK`sKKe0Wd3`m#GLTd`8Fy5w-lgJBm??+re%dDZw)Z?8{MRhG?qy<(_OQ)Vbn@^KLu$nlXohiJo4*RV&??M~c*~C*w-Q z#ku`e0Av5BU<&(Hv2d*^tt{sk1oaOrKGEgX)%hXFZ(c=xM6OZ(@V|GR`}ML%BpA2z z(LE-;2|3Ybs$^8k+MJ}#EHzCZQvT)sa$nli_q3S7bQ_kP;wDpUK1)7O49dx1zlsXq zu&?dNJnDLeuM_b)OS#xBt>F{~@?hb8+WU+Xycpt2Jo=x%IOH#O%k2 zWIQ&t99pT@d5$(s3YjPRy9VoO&v>ULKBEKFdwBz8C8gc1G=f*QSK;En=wT!w)lyY! z;bIj&Qc51WQzGQ;j83QMSfY{Qw(;j)n%s3xXl`sgWzrfG1zt zt~TF2*F0l+lf!-ZSY)Cw2QG4&WvHM_%H#9Hx%velx1gSe)(_9+<-j+a8!$IFZdZmk zi+G~aO>9l;53i zm#z`zi`Gzv8eA(+1BrfYjUW_Dm*=K~dD*{A_BJ*KX~_(p&kWID3flK(BVxz zDPYC8Dt3$lbPET+dmLX_5d)%oF_9VAI%c71{o6U+IG|^l-Q;aC26H) zxJD}@B;%!Oeh4}Bi9Pl6;eef7Zg;;jg>W;Ef5z#M=5t!13a)v7#uC{}gt=Z-mv9#M zw&;q~`Qx%D^6U5W1}F2SZ6%o%752L_otI_{q{6ut_HWrCQ3}JzRWL#9{oV?-K=!?t z@3!3PyFS56{Rr!QIsus}RI}DZ6nEhzg%3wxhTg+Y1ZSliBa9+zyaNjXJ{ZMVYKBXB zh}XdYbM$4S75ttvalT)SP@Agp%93My%Gr5b>!ETdzNY7*!#qY05!I<<=Pg}J1iF^? z6i|?K4)lF|sSn3<>dqnJ`T=kR2=@2tZR2W9^rPhSYPhR?wdP{z*U>Hujw~W;ob}d~ zQjyDw8OEDih#eL#$19xzsJ4i{FpmamgXK6mbv_-G)!T($-B0g4>12&TB(}O;0u63Y zvfHdjiBEsqZ0AoXikYR%4L%8&DnqAHzYOhL2JHEkFW4&mlHNs`!!VHmu2L95uG*vf zA2Dp|=-MGP;x0jwg07{1jWcYrQ*Ge+0RB@T?Fhn(&~v5AZOd`4So=)A{A6cu*OTXb zdAYKi7Tehjah4v)n<2D2gC8&RRZ-ReZQ<2qaF5WDd>_rM(r?J3-%OH`5~COs#D?+D zFQqL>lQ+0)M{#Fh*w)*3k}Vv%oP4gWT7v)JlE0sCs8{|u+3>$oMzezy2_El@6AuSa zCtDv@5|?rW_mQB+<Ld_FQxdMLV(d06~zIRg1Ws z7Skq1AOurOKERe57O(G4G;^fQn$(z}#*n19oVL`-4H2&$VDD+p*3j;rkenA!D%KD04@VS^rBc1{W?oz!A=k7207_v zr^n@y`sLX!)`0JskhxChFk5@_tnW5)0Cu`4?ohb0St`c77iLqtxcb@5+>EA{GjVT! zKO;uinvAg$a>ao^o30zFVv|Fg_6Mm?Z+>jB7gP*0)NsUMV^rlAiJEGDZ1rd-+i;m4 zpO@gBnb9%UEibC9I~SAnonS;knWh%Z&?+cgKH6w3TWwG~OPQOS%Bf&lG&NS;y9FO6 zV^EM^5s?t6*KyXZ6NzC4)5rf_F#MXZNL*1}j(=&_{#2lQOT5?XX^9e{n9A0ptpUr- zWLg6s1g}Jz26SlMI`$Yr+U=yEZU-!F60>tUntCL2qp8H-IGLD+dSCXX_4M@t3#wm3 z#&8<=)LwQQA~Yd?3mTRN_hg8jSuB7ogC@@u(|&Dr*3 zv?;G2Ew{^hD9UK~T`w=<*DI+ za!l2-wjHLLTlKs41N&Boo|Oo6bCGdvk7m`nZuF#o6d;IRE?J=)Q+A)x$wdteU24Ck z$I>kQ_s&p{E$fYG0i+U9t@*OTHn^+W7In9b>7VGgm_f8w>B*`J@~(KB^USs1sEXo6 z=&qa^F`5Ll(hEbrA+?SkS zw#B>D{U5RP>(SJ(%pceIbUELp^B21Fdj2_?2b)L>*so6O%!B+)YdBLtbT z-=uNJf${_nR_SrfE#svQWyrg@;IZn$4AVCeRNS1($-26cdl$j2KvexrYZw=8{+Ja^ zXNo}qF7nhOC#m%Vngm9E{(cQ;dEw@!N;1Q~D*y84EIO_&z(Uw@`q0IK{w5SLUcNNj zJ(D54&4|h*DJ6wA^|J3a@#kqY_xtTbba0qsyk`g0N(W|BCfZEqoru0&Qo}xS6Y*i_ zN}oEeb|voWvi@Q3tYRzc$`4xs8rcvWyfIXdBXvnOv?oN_o6%Co4Eu%x4HAFe_Eg01UP;@Xvkc zCuXE9>A+h3k(hZlvTVmjNmUWmX!n=lIIuCRdfc;$i%OzQJf2>_^Wx!IrG^7GG0s^7 z8r3%^r~TwnNwWyxFeEiqwU3llL&HO%oY(YV@M$tMadJKWZ&ojx`JLkG9p%baE^e{$ zd4IeF2%7_wZ%u&QMT04!g?sz@70)Z$2kve~1Bz0P;bP)w09bgdaR~8I{XQA9!B=nJ zE(uQ&K;@g(v_;!seh1C?;FkT09-Nw*iZzSz_<4JzU?^vs(V_ocQscgh-T9?yikp)* zqNb)b>+yuBOZ?vLoas8+M;7KnM839DTG~<#DXD^bSYuIJTO<>6RUzRiB4hAp1o)wF zwi4?v0^GQu>>gQe>d}rad)W(ZweC)VW=r>VT@|BAd52J7d7Q0|nV0*q3 z#Uz>_2K1=d#zq}ZE6=h(8+w`6m(n^~WqC0VTGZ5C>DHL1ntIlc`)$Or0xtCLdx&bn zGYl5YeQJ%eRwB;myW})E*A`yR==03ZX@+|^6@5(%QOe~1abI&oQve$|?uV(Vm-1B{ zbwA;PKD)VH#K>B*4JG9y;5FxxI|W^_F^Vy^nP*}_Y`9UVHpZ={(0~GBJ-Z3v{s7i9 zFrY*dKznhDf&vg4Zz0m*v_L|_WmP}@l}YeH2`@`9xaK;Fs(eUYhG6 zWktpOGiR5D0=j2{XL+|nHT_wl`yR6A1R-C~%K9S*;yseGq8{I>21SQOhT-qI>uA0@ z@8(@C{j{bgdu9Bic?thiR85NnV@%Wr_1GPV;)AT$(Xc?fN9!Yadc*=;T*io}T-D*d z8QcGPn6uwy++pA~#YO;&{!yEUG>q_3o+F;7bAmK$b3|=+^@nX%#e9;qNjsmqrqeOg zIg_Na%l5d2{2sM(vyqN^Bh@pb%FPHsLDBKAc@I=x|5zVE7=iIPEV_;aR}BNEO_amM z&jS?4i1s_EV0XZ)JdVmP!j5$PvbVSQV*W!wF~H8~R+W~NO!kup$N+{ytbIcM!@@YR zg7)Li@Cw$ppn8FGvj3f5WdGtYkqxj^hJxJ4Yg5g&2@gQ(^!Oe&Shbn;mCu@2T*bi~Ae1nc@Bw~1Bvn0%*( zanL>*fMPUX7j5+tr&3{?D=ofzq3PTgr9wEHS zT73vR`gZ>2uZ{j|5MTr=NHe*k7kxPCzPQ`30=I@ztporv%IYuLVC~@JCrV5-ZjY_+U|%zvBIhfc`d6*M$C_ zdzfGcSyxyh(`_$VnLOaEJbk*rs6~4h3jW|Q$n6x_5V4H26ZRC**pQd=r?ASy+nN z?u71++M60bt#vuLoxZUH&jJlGVC76@`aH8SYvfeY8zrtg+Y!OuW8mzJeYnz*|fZ^jYduq`a6J))GdNrp0#hebNF}Ay# zG1P{o|9nRbkO=sH4Z=D`OK1Ig0IG}X^78Tmgl2g}s?F}oL2W0=M~KXRXVvT9oY5HGg6lqq(^XPsz5oxzj~#gg1J{ngGaZ{f9GGYQ01YmSSnbc zBzkrHWdZ_LLm3p58X_(iXXl-^{hGqkT(6Se?#|3l0Vmg~0{61DFYd0bh?1|SIqh<> zRpX+Siz|KTCo%f>cWP*h8~=P7M)qW8J4~Pmg>G8vQg^h?@Q;1VS;qh)QK(+5l&bX@ z7+xRY;*hJ;dmLciHhOXST$kKs5;KhsYfoOBm7nVN7(72A+RT43xA0nSb8B?lPc32X zMDEYw^b>mA-%5M*es4wx3(UUrJ#$jm^7VbY{C6m(owV_$J4{Dj<&J% z_2bICpklBRG+%igonHk<(*RQ61`vvjtgNbxRrt|}SM95hdIr~gotU7mhb?x1coYQ4 zW$_7P9D*;vcuerqb=UI*P~MpOT$fs1kd48;DUIHjcBer7_%jX+6xjay3Rd;uENNQ* zI4g}2@uBdE79bFd10FVx#RzQ1U_PmU16*WpskYYnK*B;E3V+Ft9gW&v5O#_IaK9Yw zid_Gifk=2A2b6P;81U7_lsZs&hgn-Rt}E7;wt_J#LjJ(`l=UV0)Q7k})Z>|D9W#lA_SrOKTjTg9%wB(F#L z*~juYjn~Wb>LnG2)gnv8s~J#AAbqxp}-+60+g`MA1Yl zlzgUNU9g@loZ`w{Wj%ATd44`Tn6w)g;2T&`@qQx{IPl}9&v)r#Io((+NlTpJf5grp z@4`{P{>il8T?>CcX!t<9h7P_OnHqz1d91p4Xh#Ygf~`|bRW%|S#2Uj)wI{(}fF9KRm?wcn>JeD2K<{y_sX49P~;r7q$~lF1Z6 zoY0hMK~pMN02N1W$#=4rb~#n$d6TpdVS;`y#%1NYIUVH61PJrTEQ&6%>>wyI0M5Uc z8HPE)fJQID`p20efCg9h;&I#4se77y?w~<%gb{PMF@}5mIQO_d65W{`X49NR0DNG` zRYonEp>LGO@frf2#hQ`q&tC%j6YkM=)pNs*(i>1>P!_+Khd{)%r&^p$helle%#Y^l zw9(8!;j@s2V&zmV3k&SWKP_Eg980Y-bL%l=a&L|Fodjz6Df?R1JRo6qn$1Rz9kQ-X z50|w8s;#ZrJ?|Wt_5G#KbNuuU5U<`&gnEtwm?8?pW@1pv+wDhX;|25FJ0~sZGQg?2 zZZwiGGB==aZ5`%kcj>jCh7UL8%`G+zk_#+iW??bl-y94FLTN(#kQsSV6 zJ4%!#r+rTg&?0PprI6Q^BZnC|0*HjlhfUQfJWj?h{akhFRK1Uj>e0)lax&xPjN)b{ zUM{bfdn~;Z(wvY$ZPE2&wHQ@`KJMu!*#hEXa$1YN;_!e%X#|0(iYc3ch2V>X6a_Fg zm`*c_>vuU1;tD><)5QN~t!sQ{?)}CUOQ-oG(%PF%mnM9X$~f|XSWkB`-y6)wZSiN# zoLcxRpW7lui}t%)lCcY5WFZ1#C+xgg=BbA4X&tW6?O*$CUCI0R59D7%As}~(+zE2O zO-*7BvpmnI)o)%&*d2dxE3cZhbM>$+&9T7lce|y4d^wz)gfN`OURbVO%CN7tv)a{l z(++46CAb8LNNAXALxA3`JsEwHB7mrz#+I)xZp`wtv?HQ)9c>cT&o_<5z=E-&=(m)m zp_Nt28NAH= zY#t{J+rG&o7=yj3j308P3Of?qAD*Jn&hr9hpD7%<7-HI|p}L=cRZlw`8;#lOlVV^{G_O+6PdU!h#xmwa2dY?iIX$J$ z9CBWXO!vaM0FLqNv=`+Hol3fd6RTknp{>jpJUqOKO;a_!t1j@A9PNdUp&QX)NsB4b zOAx<^F6&TW=_K0!C|y;KU!%>y_-zuIy?xElW~ez8x=gmNjzF9okV`d=2=0bK_#EG} z!GB&OuYt6o?O`e(n`S6FIaU@JFhipn(%6(r!NwcA7isUi@8_{VR=hE;!`q|WvHrqM z=p7vmN-|YV_{?A=V8ljdJC{F*&v8-3@BX4hI{eMl9CN2;bCx-+7BIG(H<~B8?zHx2 zRFx5S`dMIVpU;*Kb|R9UnzP2&y3$PE%s4i6+`A!8#W`7Az%$!slMWq|6B#?&rV z^uOB$o#rP7E8hnN+H|MeEVpIT(Rl}!CD%^ixWHMUVcE#t&nP!Y4RQZ14F9925$1pI z{Y3y(bzBr@jwQ_eJ;k1ia3};1sAMQ~VEmmy_;*lqjc10;rD@&sa2z++g(cZkoR<>b z#w$~_LbUBsd%J&ndzBi~Nx{um3e{OOQ_APKz#t>kd5Do`?U#$q+>vNA)hLY~6`%{_ zj~MedVJlnE!Qc?y0X9VXoF7Y;E%}ro;S6KH>>^6SuhIdY7SWg^8N1Cxs%OwV^JFn! zIy}OLmv>Quu>D;Ve-yV02EeiywK#h0Q=I}{s7bHw82wqJ?s!V+9%K~CNHt({lJMBX zr_-2C7MYHFthTEAk2l#97`D4t*T3Ni`u>@~M5aHETz~Qc*te42tls;qSbrigg7HIb z9arm&=j}u@Xk)nB?0~_p^f#nJG*YU?xZK#F8!9+pm>15J%xr&_JW~tWy*}ddkNn8d zz6Jmv1Dl@{)@U1V{=2bOS+C-q_tI#h!tTjfsmg@ILHH%Y5n&ytSe(v)ZOmf&>z$CYtm1IkGV=FQX5>tj}>T=Y5a{899H*JE}C$y ztg;0966XigfXl-TXWbDi7zuL+!89nx>g1(X0btOF3ojr8jlR)4bSM?;7F$1OQDNEpTrk?^L&};67$uTH+tL^vAY38Z*sISUQu} z8_~trtGTaEFV&i4c736SQP7-WkZa7wNOTs4Uor2?g=&kLxxv<4JN33-BY1UwuZRv} z$<>4WVSgZGWj}OXMw6kN9m6s21bM>;pOi7 z6~<{V#SHuYwAxfyI+w~kVSx$Sd&LtdVr=^9rQwcj+HSE|ke*_OxZy7vQ=<0lH@S+ktPc`YdTo8zl_;xy@K z4|9D4aHIM%(m_Ai{pqVR(T873P)S$}c|gqxo?RV1J;4{@qD0`dedMs8{n&OnyUMhW zEt8Fh*P8YIVxsH*D8qGg0E>21|Hcp+wB+=hjcEm;S!!|0y-C5qa(=#>8wQ5C-zFe0 z^LZT1tMRW0#E1b(ZHfNg6fw9YmiYc|f6D^1KL`xKOf=QBxG;+ z?cq!-tohZs$RVM=5;-n3P^T{~>@DG9)`umXiix-J)^vjnOWVH@imL#_o42?34sNRX zgN&l$dAdR~kUoaU0-XNn~V+NbC8!)n%$wL>G zbF+Qb$R92tFTi}|AOzNLA|aExVseLxg}Vvha#y!wnY%Sl($V)?N~@&JWRCJtfN{$H z9oM7#r+Tiwg*1K8ggX6x3g-xBio0JIjooz>^O>r8;@`xI_hc;GPe%wSA1@?XU}NtT z*#%3ubyoR5mUXseD!Cuy@9!Pcbv(iYUGtgO*{vjnvX)kX=MfU%E%M>K>EwH@T0o}# zb^rvrJm<~X+Ts?<84U1Pau^DH_OvGFKYAU))VE|JwB_j*D__yFdqtQE5;@X%H0Y0UR2or4$jR1%x4_ zBqWDYNeNMqPALhIZjqF3q+tN*7`iz(sC&EPd*AOn=Xd@cm}j22pDWf{*Shm55pg1s z$fI3^;mCs>iD-=Bd@#1EYRjph+#Wx6xXfRAw6`z^fH0LOVqjU!ZuaY@0##|vnsf>u zn7)bCJFn4VbnT|(H&W#TMc-T9q3W}mhvQy^e$~<#I$qZ-J@`?0dFD*a%qsWqza3(u zb^3DBmWhcmdn>r#TleU=J)}LKHOvd+%;% zQgmKi|Al6ZPu$A`C9@BY=Cfk8#!m?Xx%sPWMiC$L;sZ;KYOzKr5V5FM9~fUF;w|*y z^tyd5rF($WVrRn}gmu97QIOk#wk)T}<8j>BER2`hU*MDTXZi3}gLuCJq1}bf7n7~d z-zm{C?lZPnZN`Am2*x|3lU@1!Qc8ZJ&5X5um~2IQl+4?OO#}vV_!Fr%1BH8TMQY={w+@0ER>b6qEOxO35S*Ur|nq z`nu-;xa*#m`gN|ii$E@9vTy&uYs|WV`b)G@>_KyiR(-x&bb2+kdM=742OwRhovG@6 zuwDB4Qk`NGF1>&6zas>mjk&TMZdb*1E~n_)cwvLavxVmn(AM2cMPo|x;ewYZz4SaW zQc(d706TwI569h@f>b73j)~gttP&GjGkQ>gbAc|6l=VM-`OwlAW~Cm4RcbEFW6qhD zBvZwnbjH8>xCX;bGCE=+9llOBN;9c||6Fa~en~_4-enJj)xcrbSzS>#MjxDS>^U@} zM2KtGuJIZ++41VvTKT;)5zrLW!u^Z|wcE7IH)?&h0Tfc{<>lqqyt!{)Y2&JI{NAc5 z)i23-gbpH~3z7NGx=_hPgu+vZvS$=o{fybvdaym_rVK;0CC3Q4U05)3xEJk6ngz$j z{;e&G9kp^~Xp40~aXYQIO65D|qNJo`;6WT$9;!Z6WlB~D=yZB<@W__rK8|5k2g@%dNoL)o z&d)L}Kfh;Q=C zmGk&%+tpm7IB_4i4WzpV3l=ne=7A}JUwdEE+p(?_q{0?V7`5b!guCTo zo^=rdYW?TP5J%V{h*UeFx988}noC8>t>qk$_Zn`4A&Gx-IaqE!Jk1zjm_i%bf$cd%GLc4GJ zut0WsowvMfj}HlbHc#YEZs_K{L@CSbuUnKJDCW6N>Rn>MXCROb;Wz7xv(trI7ddTS z8Zi$x%&FM21al7ve{(SS++SjIoan>jZd3D|8xDI>HCdqHY4&)rA6u4*@;c(*R4}ITALCndLMQlQB7tRCU}s=38zzO2G!Ow z-JU|&XsX^C8;&W16@Qv7=6^g)|M8+(=}X2X_R%dC!z`( z(10z>F5;!HdJn~u^|z%aX;wF2UNmxhV8C3eNZwZvtju&^(Bmh|?F-QULQ8j!x9!3( zUA9TR%OO_5rViT3BkmE`1#=1Y6LmQlT^P`h67FAHJKt%(4g{G^g*`pIclqDkT&`)W zski~kY@_yAseYXyD(XV(Nog4rUgBcq{<0k@V&3j0`XWFW%1&DB)40Zg!b^>axOgza zC-R=Z0yeFTe4A*CLyQP8$;?S2SQ!h($9rllF*Zg1QuBR|X6F*=)KvRM)NI#S6xV{41W?qvLG)P*n$>JMYI?(4j09Tpkg zb4%_1oA7()=C3|26QWS4YXVo^*CpJMc9So+f&ezV-JwH^PpgVu?=Gp0Is0JA$~xXG)t08T_3xXf6Y+7S7R)eY2t;t{-Ka9AGX16Ce)JzW>zHq|~qOHNL? zws3^i8Ki~;)aDDge1QM^=e3eosCjvLRUFlQ$La*du4yZMBY z_CX82aEb6bkl#U0KYLvukTRK~o>S=fz(y)u8ZTeWb&t1mlX%iYZ`^SJUxjl`-hZI7 z707Ar9)^zyZWbo^vlQB`AR~wPWvi?w+tiror@s9XG#cFcvNWrZCs`N3hC1G9ma5HS8&Rn zn?3FVqLZ7u=ZeCp~yMCr-CQ{u&;h!^I==j*Z^c^!{ z+FVFiL&Fl2L%BQca&p&@L4tXt=D0>SooX|``dP}_Or$lA8?3pFC;{0HR=00Z}jYkVh4yp9C7hH3F zc#gYiea&_)T1=%k9pT-4ZTER?Me|jg zy0NP%eS(6Gq^8{BQc!ty`9Gz z$8_b&D-^2c#?x+6BK0(y{+7(6$0C!0p>Lv5H;B~eyH{V7k_ z=8PzX;qQ`=z`bB! z56tsqYe=n)whWRa_gzhGk26oXZYho0h+xF#CTh>eTkT-H^T3KiwCBBTd=t%#%Kh*V z)u9w2KIY;eLu!+gqD4!O=E}0Jgq$PL|2bAZ_yAd9ksXMPyp)m66K1`3h`{^o;C8323Gv#kf;1^v0*pZHkKMWVjm)7(~ zgxB+EbY&H9R$ChmoJ(+`HMEN<-a+{r*Sy3>O*BU}rr`<^Mg**&?VW4u8&p(E&4e>c zBLy?qxZ@0its91xs`CTI3%#q}GX;9<;DGT0vVOj4TA65KyGE%8iirj?1b(;YdP@A? z;RsNKkZ<3q5J0mOR}XBj>u9TIK>O20e-j1)2@9{BlG2uDqgc)JGy1~e=O{$Oqi%3Up_)(?Q8w!-Mmw5W_o7A{Mvj9djK`~RAJ%lV`o|GX--hn zq&aWXfMT0@`Wsx>A4cC+>Vpj41?RIR{_YWQd%7ycmnXwTpV)SXPOQ@77QSt|h{L0* zBsuDXT@Sn~KjA-sQ>#Ck#cTw__FCeR0@O+2fNcZjfTY_#-UQStUFA3FXeA@U9LhvU zUXxxG9pED%0~jHt0j*^bv|I4Z2+dJ|;6l$iQW+9p@h18fKIsGVAVNqs?Wn25i^soD zdG8;!`usUo+-BM41+=)&CqH+;#^o=*rD>MN@fl6Pvrw3RuU`mIe829~N4UQ*d_F)= z%Ynokhe0tKBhO3xZ>p^FR_A4uhw~lyXHy%hT*-V^i1dj1TtA9~r5C zos){r?jJszN-AhP+Iut2=0pf)N!?nent%Pex{@WPXgk zM2$yV<4`5?u8&xFiCC($bMzUXwY7Cmh4VA+sfNAHsK~%hDsZzc0$`L8$;fwIqCTG_ z&#{>c*pVoz-G%lw+HJT)SKgYr<+2YwmMp&CAZ?1OIO(ll{&ENLKNcc77^ z)XkSMwU6YXvYYc&=|5v|`SiJXW{LFNYQ9JJRYgVRDm(itLHkA77cZppZDxWL+X+-6 z=@Iv))RS8?t^v0uLe)ViXzYx~a!IL$^hLOg%c7NR53bI1qmp%iKBsKIhQj{S0qjNS~~v>!8~WHZS>2x3&G{h_K*iyjcs4FoPU%=*5*xfeHc4n_G&-TFYj58e%= z8cuyv<6|#SVtX*F*Lktrte>L?6co1=6(=HGD76z+?{dGwkCm!|;Y|v05g4nh3jh+R zt23z~EhRZv(fZvBi+#91(iUxU z!~tY>BtQLqgd}GiQ`cI0d&I)~2=NNSo`&yR#`T#Z8;SlQM$Fw(&#aGLgaXR@3Nua+ zzAfsV^;Fg9cJ}CD9>5ws>Ej}VZAPB)`z=7H)aAAMF9|4de!TRdzwKS6{dU9dMy@93 z=gUhTnt}E8IPaob!@L*wNqEe+FXP7y|V zG_;Nd*YN3Mf0+C2K2e`}?H!N%Q?H+|&rSY(JQDl!Bbg>c16Sk1{-O3iotS7KKJmUF z@crxq(}EWCHH+EtEbrQvDAVeId`_{UA&ahy})ybU4+DSZFB;dm;gC(T0kx|k6P;8dS zzDV7i!&0U8mc-YgQlLN%e)7JK<*gkf&!@pV)_3!GF^Q?InXA1RFR6$Mo4z)r*eQhbK zenZ5_pj@YZ8<<^Os%?pQ#cMGLzvNVRHe{>Stl4DA*{rVM8mnJjgf{rifbCUjXNQ^n zURPS3f%m}QWKP%z<=-lV-|XO7C{m7r3u{M9IdjFiv9-n#uCRw!(@KPLZGjssYi)mM)178R8*c1y*oJLuHFT2< zK>+^1HCm&&G%>Q$7q-7Pzsz^~dxmo)5A&C*vB)ffPK6J#I`QDzn5oR`xt7HvZ^jCE z)J5Pb=g`_uEI3+$)BQ~0_2_%q_8pBf-dy41@b)`jb4h+b`r*p`{e82g1oh~-a^i>4 z*ktC;C)4tbr*K#*x$#d1h_JGSi)w4OZpv7)GyJPNSjt5RKEf5n3?%VNn6G z2nzd_C1yE;IZVUO{XuLK^HqyCZ(g;cb#Ag#Km3Y<@dU0lzup%tr=#7OIWQ-i`d&Vq zbqGoI=;JFwJCZ)5TOIt9?!DTnX137597jfs_Y^!XFJi1ate z9XbkuZr3bdOcpk56YV+)7u^1Sj={XnOt?F8b$t5D?K=PygdC)kL@ z^|nQnYV@m1e6N9Ru*v%OmACo7AlvQb5nkgCakP8XyLYm{S2e!(L;uxXr>#XfU~7D_ z*#WM$0*1x`Qx8;sz+d%3GmCO;GN=-Kr-46ocdUr>EKnoZYSQvQdH0%jV|hf=_ZH^7 zD_20#TzBe?SCp{GzHIF=R2jzD^_}I1C2N}K3Vi^e)*D3cLMvmTwXloO)JkuJn?(?v{we@}3a;ew%7RNV0_9Gvz^^_aTZbgHlSE}-geAs!%m(a1V zp*gCB^kZN^`a5$U#fj3xwC7WcY_#h}rwn_k>8(m&dvUEb0v1ljp>?$@MInOWh$o;Z z#8ZL2eWU9{WfHx*DO_x94k9AI-6+28;KyZ#rB`J-Nfirg4$_K*;4fUGbq|&%=3JfV zfS1L(H2BWnqG521AB+oh|M|w9LkwRQU&}MAC@DdmcBirRFG~(i)MN**e8?`nbpWPY z2xkk2EZ)<)4NIM1`X)qHU>Z2}WD2>D5&@u<^_lk6#l1quat7zsd3$?BiHrw9?Z|@y(F~t1i-e?_bDlNZ`~3$AIw+x$ph%^ zt@rQW>jyBn=LT3Ul^-8LYXv94%W~!yk1~IoUQB2~s=%Bfz8j4E|kE-&H6<*=L0=kuWJ`mn%bql>yx95-Q zSx`^v11JkEbT+TDvL<5VYE^YOgMgag#VPhKz@}K_bfUz;iF->jnmz-M`0)wUd@}fZ zZG=BnIga`9fHy6YA+fCoESl>2e3}o=gx5-vu|NyP+(W??Uv+g23@@6K$JD7?ed%8x zPvl#ouTuv}t#qbl%s>D2(FG`1 zsSkp7Kq)+h}%;$T|t_hW~_x7mXb8+$H zQMxoq1@u=P*m*$Cqfa@)>!A^b(elQZcJ=XJaW0U$m3EwHwDMC#lHm((6Oht1fO2nb&z-_AyOVn=- zIa&4C212jpLGsg)nL}{tJz}g(k^1TMp8&T8)$6~btAfMum+VS4;U z8+A-67i*T1bB91@I&MWCGW3Jc53YIWY7UC9uOuTzj$$Dw+ceegM} zDIs$&Ev%#yh|JR-8dL;4KXkb?M_FyJ&C~^~W=%r_?e6wImy^_{c-xmHB9QbwWb$R# zNB_W!3!=y^1ETL09qt3C@ zTSOL56o||YqczBJkZ`nySd|^#ffY#>a-uq-2VGiVcs;6L@Y+>2h78L{YTRV9-CgrK ziFX(f#Z~e>;Ey)DL_J8NqtTl?)%{C1%X3_vNok@B zk~R4py?u=$u>_yl&VNbE7hJkIBbmw(aQj0yjjhtpj|v{g$T7BwqfEL}@C^+OuN=LC z$Pxn^G2juuGt)vFE8}P;CpzE1?5Uogh84olQ) zCuJlgy030Fr|RB}ak}6$UDkF$XwEv6jMwHagx_TuAV_Z$>?uy1 zV5WVyx0!sK2_|CTL~LO4v@(7oE-85b+#Gt0JuEC zYE20{9IvusBjyM4`tVGC?UuY7Tr*^v^qp0a}>YgR1?BenTbWBe?m;Z-y^QPR7hS~HtC2$ZT z+bdG!qhfb6HFK>5MEG~_qAOs70g-e(xqBESmy)zHb@6jB~8l0GDcM^(YeifD3mg!CO;z5*rHvaks^;R(~q)WaUkoG@9sGp-b%3vc+&c#}9VtLieOE_^ z2A`7W`PKk}Ho*~SuT6kXptTB(3rg3zDF_6bqBkySLbfVF2!0C^5B>AX+R_|*c9Q1a z`PS5yZqvAWkH+i~c;>-1${fD8L;={V!f*A- zd{4T*w2~Nlk`j@*{&e-$OrR2ofgr5SM$h)VxNBlJ=+)-?Nm^3u#R%yveED{0Rb%GdQ8HP;0m3S(0Et|n*qLC`IQ#a_RTICWL}4f_8eU8Zle( zHiK_en|2tiO*6ylZYF*e7+Ll$rR)!BrH)JH}g>4$iiTKmE9m;fZ~^rfO)S9kEZJ z<3-`gp9||CxLUnmq^!%5CXTHyYu6fIWM=`WHjIp94{$ep>j;D4<1(JH)-)-$XCI4)TkE#1Q(A(SW@1L96|w(T|Mo=u)l9U(#Mj^N~KuEgw)yfV>(sLLLv z(?Vs**)|l@Z->A_RO@eJV&R^?^H5J11Oi@_pYQt)J)%c=nvl_Cb=Iq{RQ)(wF0QIn z+$FB1@v~3f!ygv|zS2*PQLX-4^!n$;EFK8CDV7nAN@O~t{2Gi0Jdb@l@W!qgu>5u+ z(Nmsv%h0oAJ9_^N2I!=O1f2utI{&I9f)PQVX`%L=8|EBi0pn~+`e;lhf*p_X7CRYn zD>62FG4YSTB044nB0>~>1-da~l&}ER)m??P@$kTAU+&)Uv%}xSx9#q{p<*Dx2x3Yn zNF-!G4&rkmI=8L48aGDh@9XN%rKa(z4LtdgQBO~fJ{|}p`Qm9klr;Q!fA>OvyW+P6 zl5-m~%dVuKUC|y1#(0}SC`6iAGN0O81b+m^8fzgN~(_8?gJHdGIaux z;T@c;oU1{_bN{G=r2B)`?q8P#xkAs?WYK)a-%bo!{wB6^Ue0tZ7dBncO1@BD2p!3u z-NP8++|dT57~nb<0>}H%u*`c)s(7~GdjA^f{tSL*Uj+l;;Atpwp<3QbZiN3{AAJ%5 z%k>pR+KF;=V5$XQO-CX>?9OO6&r(?iP>_O)KMAcY#o|{8t zm7IzZGc%44IW>q+9w0tpZT+XX@FI|wDiXa}M_hI3R)h9a&xhNcHnd&CWn{rUD|9j%-<}xlnYu7JXufu9+MTqA^Ao4;P9}$Myi? z3q>ZYTWw7=#QBD*8%Z0j%dvaizmP#dnp5- zz+aY5lH4&%cIfW$np4E}PTN7Fm&D)p}k{g%Z^O=*<(i(TO60rdv=6Mp^yYNoSn{yz*CVsMOW=6{t$*1VvG9N4= zUZ8=mU}OwFlrkSGXaE&azIYca{5m^(!I27CpzAFCk^b*ttr#1jbM@-1K078vT+l<4 zFZ3kXug-f@M2vnDAIP$q9W2TwUob6v&^;DZrOsv`t?0Ho50xcYWo)(Rm9z@GH2#?v z_g`Xc`AKm{v%eL8j!=iK?=8E!BB8Rw9X3YzIy49AJ_Y&tG^C@k{YMCi*j8)U#=SwV zfk4DT;r0~+Gz$(V#VDroCCQ{5F~v+x;b+9H#po;6h+HD0d34xH8k)X9qg}U#Ik}uvAm2|1Unl-wUI-_$aVCnp zzrqI7aQd2k+N9?Dr&YDC#OdU+8m>vEHWbp@z(f`_x!Wx`F zB*;zIeP=l-|ER%zQ>KS_*9QxeJW`ise#M*P_a)D&D&?)}XaDWafMjJT1Ihu9sYibl zNZumw_45|JSdirF4)s5(B%K40U;-RVXKTcQC}b zQ2%Ywq9s7P)7c}ZI$TxuFjjC*Ph)fQHb^iqodd{MOe7tg^xHG6|4^&3itRYv0*5#( zyK(1gnTd3(X5iuF-sMawtM4#YG4ZGWi-6n&lAPUapGUR>`kDSnpPlBMSdt~_$-?p6 zxx0(;8uvidGNR(xh=%HvTb;PT9_RU8b^NTaOvCTS48?4BSE zQS^I)e{bS&-=1U;zPbqm?+$c0e{0$s{C*=9~ldfbrRWQJQ zD;FqQiv@>s4P-8fA%$Dx#NtHv7hz@ic&UJ-viY z!DxW|cJyUwX7!^mfxe(*!0LGZ~Tp7pi0%znJF6h~me#z$y5 z@-|diGy)7{T2>IGW@Px$xUAk>uq&^<9L@dzq3kR+F|_KdG;=OpoBmKM*c%WjxPluK zYK(mDwO11A^8*@9Pg&=KVJNoYWN1LE{QjW_UeR|TZwIr>C!7cy{@yY}944ohK zY8XVv6uyK)S2rqx#Ey!=5&yEfW$si{Dm$H+lI`6%;v6}qs}WtL{kl_CS}K^4l~o{o z2J#O3CjgVU>p{sEs{G^3&*8PLt*@H^H4@6-%MZwW!z*`CxC?d+pko#Mqo4?SjDw&G z1l+C9ahJ%|d)1Y|WkN+!(XNFC144rN$8q&q(*%}C=KFF$l#<~<`lz|(HF0x8R=tfu zti%)}p@`-NE=ar%tB*{3Gu{?(P#yWNA0KUt0kc`_FPzZ;CHylWF!tS%-ljwx$RvWb z4GIm8SUXT@CsPX*-V-t6#{Nw4v;QOhN>~{>= zsv~cvikwIspxv2hzJGrx%EA`|f+cfR@-l1R)v+<8Xx*j_=~k)anZptx!OeBehHnK= z_1z~qL_7j1gd{Va_a_{Ak8`&ZaS*3(j4blo&BNhS!2h%-n^02>KgrbLmzX2JoL7k$)Cx+`9EZ8rkBxzzx>e^SPhIl>Pz`E%G zW~W*G5O>I*Xw!{n|3I5UKtD5(b7X_DWZf96Khk`oTtT;0lLEbKq%%fd+9%sR_DZLYRob_v_4#6Os)6 zz?$l_-@;6F4)@~H?jy}|58}9sI{W;xh`nIaGp-3XBwq=IKxFexJEd2`O+g22GeFD` z1G)JdfW+7bk-iv>md%reNz3od$WZe=A1rJo&FRn1iU-cOvTVR&>t=qx&f#o&YV5_F z!$-hPf%KR|=ivT&WFwVztzJ)oS;E87!YscND|iEGCxCmCl43)M=zdiv7RXgTEHP2K zoO?8fl^dqH7Kmwr(#_f`e?tYlVE8at_Wxd;_?~amD6&ul8LiLk2G}-1mH77IYK;nd z?=vQ}Get3KF6P-NX@bVxyAL!fU8M2^k#IbN$8hp@NHhd+2atsV8a)Q~lSXFK9yyo^D8&$9c0RZ85T6%kh(sipURW$@<4xUUmy;U1J zZ~~i{qPC~)?dG~7P9%`P3gJ^B`=w2w%Z6b>JONC03Zz?{^f-jHLeB%MHn4J+C+Cje z-&l5{u2%Q7UdP0O{#$zzB#DgxWY+zo_qz|O1jAWb`#KSE>>oX^ob$d7aPR4G-HQnq zgZ99-4Q5TPt;9uEYd#;qCV^pKQ*o(yX3M!LuI%!1tpI)C5}tSVqmH9tim@1Y1CyG7 z^g!~YSO6BO4P7x-U#uC50m!|pHp@Km5y0tC&6S2ySi5ixm{~O8zP0?$;APi*uOY)X z)ON17)q%$7Yt|yOflt$5Hwh5%X~b{waNC5b>use_)y~qG&-N;dT>^ z*%H%>f4Du*4l|xu*a^mdhh5;M@W~TS@QLDRWEFtuJGhzg3iA&2 zw~r{tn4LOqLVY^23JcY+^uV^_zMBCsf`d_~1#{vIblx|I{BOf0mcH#SvkB0Zr}C++ z@W~LzgEG9T6>Rc=i}YIg5isIt3?jClIIGpVgc6?B*U->(tL<$4-D>OY&dB#>iAs17 zR6{Ev5!EVYb;RdN#&IT!->^w0R`~kU4@Pj|*Jg39TVyvcj-iYR+b9U4D4Zz_3ERF{ z9^0CETTX+`nV(ZkUjen`D~h`MQY(iGlw&$y+u_dWI!cC)^MsRaqWgGgHeSbt_b!~i zhk1h__E?Y`^p^o#0OKs#&%XTIVQ~A6W<0~LO)x%184&@l5~bK3kom#wB;D5om_FUbF5bwvNteQX5PS!*J) zbGuJIM!*nDYpY1)cXH@mG!{Y^S#oE1?dyu$d^+PSU<%1e&pt1zG0)U0X)5J=BNOuW z?OWd=v0b&6m8xUC@FD5l62wH2`_tfhj4-_FWB~iVqH^Q`OrxCf&U&|RqpnXSj-KT` zdLSyq7CZiAu8X!V>#fWF6|p!4Q3ONq2fMLjCR5m))YsD)+8T7n*`vDU)r0tRkVCugH;6U2w%Xx;g2NX^%2JE5dDXsn2_rcsonO;c9TcapY_%7s5p3Mva*i)uJ2joMK8c(qCOzp<^Vq_mCKoFnr)3e~f zck(4>r#?vgZ0fchc^ZwbonM)2^2HE8`1w06o1Y~cXnzZ)wU+>F(cyb5A$U^*+YAVH zLVLhI7iV0YnZ1p`$S1p=4Mf(_p=aiM>t%yM0xhux55Sy*%OJ%JD7ELMwrcXwKa3ti zbRsz2Shs9)+#X`)dQAd&^Mu*LB&BxyjoBhL18t)#6l@b!sN?Lh9|(4VZ~FRGS}f7s zb;1|2Q}s~f3}iFDliWL&UBBM?8Y}Qbeefgr z{_U5p)({?A_GT1pYJ>yT9r^%P8RK;}9z1-%k*>RgEC=8`D_Au(0Oh0@BT}LeafHdR8GG}yqkg^pFr7nG&LoJo-HRI5Kx;))Loip9%@f5 zMp}INfh5SJ0$Tdr+WqFbf*N{6Sclw$ShhabyQS{8kGVs)pRz8OM9uuk0RPF<0p9km zs_KQIxXF6~Kt|2V&VG?aSZM42Wsjm-;u!j{t-bx((hVS0Fzl2CAx%FN4sZSu&a6rQ z8P0mbvb5SBoVT5~GcfM>3iYweJGy^6u4;|)qV1e?Nmis$XJB9KDc<>sKW&T?tDx$6 z)ic3;z-JoDSS{>O{%_cB6s2|jV?*?l2**}43`qQR^~_h$0Q*z&QG%YM;jc|g%9Cjx z^vV@wZRr=T=j+yLNNM6fT`@%M8jUz@Yz_Y@TKYrB1jxu!Nmhs_ESTy~e(WRAFoXc9 z0pVT{A`t?Ca8VMp0!>q9(8GIP00+SYoNG056^q|YEhj#jU%2ybISAPUL3xgM7;T}sc*jWsn}0AdpU`2vog z!S!2EhPkFma|%B1*4Da7`|C;+rl{M9&BjRCm>*Jf&>Hgq`!t+mA+n*&N`AjF_c9D8 z)V=Z7pZ6!x%vw2P_>*tpeTxLM?>aIij*9&l$HH?f`34IFSO__spNBcKANU`5&qSMk z{lQA}>jkWh315|UIYfuQueI9FP#%*tJ5;Jat^PBZ{0HW8_FGAC#l`<=GN!bGT zU5ax?wG=pyr=^bUeP*JrP*R-`uBTkzt>p0lJDo37{{`X)336czb&9#Y9!p>ZQpBWT zm%mf*%GzZFF=^R_F9Rc7c&XuQbh*m~Lf<=5IbGPc%y-%Dl*i@fHCLUlgco`p%eHVkWS^l*?Xhcy*A*cG)2d z7ht5Pl0u+T^i```9S04^$l#~9t!6P7E|A@w{;qf$T3P;#7kU4P7YaH^A=4+u@whbb zxY9+nt9>HE6Bw+TDn=q>Ek8v%0X@>)j(?!DI05kH=H?{p$$H=M#Rx=f0+3v({t7>Wv9Yh_0 zg>U!S14z$)QJJ#TM>`E;{$f@DdkWtgb+!})e&w^rM=Ll$vuiW|L=qSBBQg-+@5<4QI)Q7jyt!Q?I>*zD7!p6e*damqo-q*q#fg2O*qTvf zcPfFMhRCFArmaQe#ulys9 z`d4M{DavlKO-<2+>AEFPuYkh8#tHx6r~C=WC8h)Hcf7kK)bZv)${jVOu$@Omu{+NO z)l0yCi2r!L?&F!o#qWTBKh++P9)caX#0vU=cfi-4DmORcmuT~7s>fim)HkjIxSfHq z1}TI30AdL}0oln;qcP;Z`MPBT;oolnCPDnWRjU^=HrC@`l3L&{6ALd-v~U39ngiZG z16LL)x#GpBt!pQGGVU*EBkPyeY(AcB6$?y%;(_Y6$%zo@+DH%Wmgj6G%Bh*y9yJIO zF`j9o4h*q4$aCEqhyqL<^QbZoLfB~moG1ZkBQ?T4sUOz*jS3mxTM)NU!Sea-S#mQ9%G|8tsa23 zwH~8)!kbnJK!0p+!;ja8v(yHSNV~a!W06@N0cJ5F`Pk;a3Aw%n`5F==pNG}l>uW7MccGh&tn2+G?yBdP) z#DWBmkHGG|9p#Re{`>-u1=kuG=Kw-WTz$b?gK8A)sF|JAzgsAc#JPT~128~)%H8^^ zJOJa?w!>?McE+n2=uqPUZb4qW12mSTZef=#cI12blgu@D_U)Fn=qx^*ygpf6A(kcp zmD^5Fp1Q-zw#YE*f9`moQ-=16=yj;^)_8zfL);6os!XsbjwE$mH!o52IB_6S(ouuLy=b6ZVz(s)PLR{_7Pu z5pIIvE;{ru&r$Q08gg;4gW`GDoeA&2nJ{YmZ+a-Pmoo3rvw4z#jkusa92gLANbzmA z>ZnZ$1?`8FL(NTps<(AF@q*x?374vLK1~U6Yb*k9cxu=6)*`hX2F&>DIR`O(4E{B= zedeRX8$^ysW_5=&GJ>E!{VC&&)jP9+LPzGQPYHpPwQ{xadHfFi{A7IoG81+ANiF}Gtc9{41k{7bzFyEoMl6^iOr zt4n%21ymDcl`V{zsR||0V9NmIL^3UMbyBhM1vvtu2U!~Ciu~9IFWMst3i$fWs8^}3 ziU1X-2VNUTv6=9uKw8kw9nQmP?7h9#oLbS=cA!{;o|IoffX~Oq#Q3msbBE>4LnC|w}JLgOI*4;Y$M?5qEWw)YHcYU`qfQ4vH06cj|HDaC>yJ=7>D z$f2nyRf?1ZrAqHnP*G_LN-rV;N{bMRbd}x`5a}f#5PA(gK<)~PqNl$1yWhX_m<=vDwJXj2uM~>5KJ598|-3J@Ibb3XYtzsTnI`-_1 zhMaW=Te#f5#=3A4D~HLWtZj4BE_IpJY85UTbQjQHE#sdQ0?$LSp=LO)=9#t1Yq12- z(90=}yMR7-gw0f`+l$cvY3tyPnrSlM7xZReaTPETNFzb6dzD3X_=ggQ%cz{$*9@@u69Lb_ zugyd5YebMH(%_7NeR6Hcu6fXfDS|DxJ^@Nw9*1_%|Co>ZIhN~mIZeK;6q7qrjy4NZ zP0~!aOXu)YG@q5EEn3#BGXJ>GSF5MskWQ%u9SU-HV4Sui)T3AXHQeF>mhGfX={|N= z9z~@)%l4Bw2zt7bu<43%m)Fcee4}z4jSOIw(v)6nMH8k=1^ADwwEFna$+qhX zfH9l_;Si21boUmH|4xHVl2=p9$l3SRrz{i<4D@%KAK_;^he`Mh%vSAZ`Gk0M-*d?y z--sC-yPcAe5f5_ksw2fb@^LdYl_;Ms8^6I1 zL1mdU!}l&)2zTbr5@yR5n7yDIYeEiRpP7N=CKrYr)H!2TJTbk5$3XQGmOeF6p_k#^ zjBR0_krJpcd)dtNT|SYO><)=5>+Yd#2D??gOyG$x~H!??YK(y@Vrlz_?nGX1EyoYHLw$DD1B4l_moJk5L8 zW?dVn7(EHnO9$Y}{}~+Vy!?-tX`A_rX5<7@CP1f*kqPZlSP67W=Bff<%7ASrJkE0j z$YWlC2HRstFOc?bOT9tQz@sjBJydW%`%&+O$u7*^XAXVXp*{uKOHj7=>A*4W(LGwq zi_g3msjf#g^>G9$2IuegY?4*ZjvL+2mNE-}oLA}P#?1_RW8vmccq#iH?~g@nJh(5z zB(C2NWW3f_6m63zSMf&A@@~!H8#Y9ibZc)l&sSwhVSv<%ziV*S0}xI&vz-|UNXU+M zu+`zJI~!B3pDq{-RxbZCES(NfKRN@(2{nDK@QXtDl}nbi4`M-OcXwAaAZe1sRbQ#l zhwC$_KJOryi#+w3$rozv5aHg#Jbwr*PSq;pMg z!{-3}?Jims0B&xo%>*D82QlI`wYLiv0Ec#OkWd#Ok?6-!QR>Qi#HOXDTE!|s77vK+ zL2v$~Ik5(LjHJI>(O0h7n?c&3Y-@$dJZTaJM(TGA=@l>WhvnxKd>rKi_F3gUiRmucxq^{j9m1n zOIjP3@imkFBOtj{jq8_79x$}66j_%QFIzvN&iFKf`4iIxvU9bchA*;Nm4)n>nG-+7a2NuQMp9L^*uY#5r5FeQ@* zaO)NADsQy$IxYazBu?Sun7wT-!Bg>P4??F zGc_r6W^>xvRyvGaI~OIcK)hP_*Qiii!6W>2il(FmRgl=33qK^fytB3%Y*eB5k_~W} ztap)_PD%*8(!#?!{wjrv!9pMCA)~^SwMy@#w>z_pv5ZVLPbC$M6}m&1}(I;ig=Mt@rUvF^%1j3q?5pIu#1HOYadPsRSQD3?CnW_YbEo%L`Mqp;y6CL$Tt4(m=Av z`>4sD^LXS+SENL3sG}m0pFZQuB)`3xg5TCm@m!-uhwFEZRU2MlIS?JO)B6Z13|?tW zOkF<5(uTC!Fxt2e+m{iua2^rs0dj)+X}G?sD=WTy&Szjc*=nfkt*U_+zWf}A`DVoV zrs^13^Z6l|ynSNLa_4kT!}KPw0no`BqS1#P-@)=5bSm2^#s^q7DB!^gV_y>A zxRx^`4g{#k^|C=mxD>x|REz9fes;7-HZt!nM(g=`mJ7Bx_V?C-H1oqXioHp$%k7*F zYl*y%=-!Pi0FfxxAZd|RO2eSb!-II1{3qVKlb`ITWCq+6Lv)S6tAG;3f!&#$iewvh zr;qHRU%pAteC=}e@ab1Hhc;r7wmnB#Mld1j^76$l&D2vz6g_D>_4!KI+L%UD#Thlu z-xRNHuzE1f{_yVi79POmKkPpDvxir%cI*=h{ovx)O4VVeD?}{yn_GK@R$6rc=W-a`1wiM52b1=lvdcHm;%P7 z*OyOFW1;(Y{6y6Ej~=`GZ!mTkzhsH|VnMOKpL=6x*7er$OAb?=s%xpM$7+wuME6HB z{3QksB=E9^Bn_7yAY0q}>>?MyxPHaI)rtwjTT6}|zHaq7lRqW$Y=L8cqd#)jqGT2{ z1|Nhk$JCYzB>3`soa19!nmE33J?AZ<=2c&!6g6@U0a{lcCh@Cz3=G+$8o@NlJ|IMFy6Nh&8?0hXIk4 zk*Gj8cIs0OE>^75Qr}qQl5pd?_wQ8%NzZ>qA8(d=>cjNaFCHbj3@aWBbt^%y9vXYd zzG$m!LMKZO!<8gQ4$lv((dV;2Prt#lA+t~*C^9m*t9;Hqs6>8R%~R0>$_HA2+NA2l z>*MrqmC84v8qk>jD`QxhmlbhxKC9S3@rKLFv0B>?jAw3;YM;MQ^9~mrbYx=ItB7oA zl@u?EB^E4@<+ zLcy+yUWn=W&Y%3Lms1vHRUp|Q{MvK-T7`kf9Ab22BBqo)@*eCHttPM8;T3-IW*!cI z-F0pF^o`S1bgTG5PMhZL$q%yg?!7Yh zwY`|Vg$w(Mn$HzW6B)QD$sGnD&0lQV6abgW!qw6pEt+PaJyC(G~>^Epl>*=f^hNR@-FYq)Q}Uc^}K&n zto~Bpkq&5M953b6IEXa%E%ngLUEUstb@$bM2L`=eRTwEJiCt6yOD zA$;z9=d8zp-5X5@_q}7^Bcw_Oo=g#VChP=i@Ju*-P_IfZii~C|S43%QU2jh3?Ue(j zEql=Wwi;@rP8hY%PpR2VoXaPaOX;JXh)qFY{i`=C9yvS@@rN=3)+yIr?Lf9h8)LhV z)w5slt11{f9&AwTN-^E>RZMmHCL2T+h@JA# zjcL*$N=La(VMSc)E%Hj$uq7)rfaY^%1N;L4(}RZ&C02<0fSx@`K|*G{lk*7K%S7*Y z`?4>L0ncUk>)bik*hqfYDf-Mf@h{?y>u{{PRNmz4+{sQ280-zVwImtGj@Q?1HFb4Q zJsj%Hq?0M%ec|U#hVSydwb}O}ttlhu@)4#sE!f?Gt{x};S!qn8aPkuW53%#Nf4%k2W{%&f z?h^4;gFFl@nK_&}MW739wAX@`VdtIx^gY`}tAF}64`;eWlBE&cjuX8~e5EdB`1yt+ zq`pn_Z)xcG;jSvD*O?^|gvTxPSU)%6( z`9*B84S!TrKNEv*jQ^rmfnc@Xq(KzV<^Ehqj;d{w^r)1z-k)$pg; zwZpil@1xH}BY+rW!>%)c>6V$=#ClT$ZDhH_1 zm!01LI3Hm^>i0o`NzHoWTx&;#y<#IwLDwO9AW#bJb_UG1VBFCKU}La>f9VNrkA+}w z&d^D8`ZFVlWSWonFgFfq94??9QFc_o8^9a%0T1 zoJ$Xj61T~Pp`SZUcD}H)vrB7}25@7oj-H-HI-Ytv67=JqzCT_>wbBkrr|AnCv87kj zEZ(FhcPVUyI9c|@14Gfx^n(=Y_eTo?I<7;n>u?jG5Gxyc%s$AGy|lfnxOsN|IAHFw zZ*cPNhRo*iHpj!CKN_mAXKM0iiQ-AEBMI*lHcuE*D7tK$OPDHb{yvO&9$3$4kNc4S z(~F>MeNEQExmq@ndQu$uLK`~y`RzRZ)gfa5c|$a|eQ)bq4c;T-)rHxzO1JngQ2AagUllSZ84UGxmd+z1;5?X6PkBV&(&Rz+)Uk zq*Q)>{xxlF^J3g^d`*P-dU+6F^ju}2`O9kN?{&OC-N=4Odw+g3nsnMQ=TqcCqfcc_ z8bQVGkA~W&qvhkIF+So#Z{81E$AYpZdsG{KT{Zl|Zf$e zeiTOPPzJudJ^DGZfinyNgoXd1*4YYa--MA{q@PU!^EMA15EEZ#6u9wPy*ZK0?^)28 zsIJf7+S2l+sZ=fh1yJZ~w0(^|xYz9QLQ^LRgVKi{hC2NG=g(dO8)Zxem>vs%@^~l7 zOKZ9akLEv)Aqh(cs)CYVoYwlx8|Pu%@ID`4vyEVWl zoo(GC2~MYE5GFK`2E`+ZVFs@boz~PYwXf#%Z3J^OM>@cO$GJH>3(ul|4u)Ul)%HGp z5v7WVwig8g(*xsxef>z&v$z(IYT)|54}-`9At5tV<97kW0vL)uQQGD5I^uOkMj&u| zl$}Km0%lM$DO4{s#Ren+y7NyN;P2^`?BHuM${5LTg|$1+wY6qT_ZAS>+&9DDCa*4t ziyZze9CGK_bxqCBAX7+p1au@G^;i`!Z#Ws~5p@4Kx*m|S%VxIkKxw>rsRhrJCM@b? z_YJ96uqt>&CDCr(3z-kf9xdSuCd8`5AvW@Jv<8QVo54_>)7EbXsdldYQ_vuNzYeyH z(Bw*7eW9(Te_b^%;Qe(K+LuzD{#!wn{BZQ_>mAarWKc@gER!l)puH3%b@W>$OSelt zf6o}p6_(9Wca{t;6;n>~gklkEGH%21uE5R>tnY8JeFp`+hT{@ zA2}}MIL=Qu*xW9PJrmi1<1(lg`lO-bWvJF{S@adY9n zcVB#)M8aThXXn1$%`lxF`EQNse@M4}jZGv%ViU|HLkj4xTwiK$EXLT3JQz>*Gjq3P zOqY?`28Dl5+`j20y@ek-ro=q?3%=jac>H6V|F@5TM}rW=qB)*sed+~P?)3LYiiwf5z%0>;qy)M|`*$zCOsYSj;o+8li0evN-N7$ zB0PMDK?>A^u4*iq(0P%AvfS)7Z5PLy`wi)7Q-g{@fDZj6ixB1Rl}-5PtujaM4JFzznLS z3+58BS2V42wJ{7kXUfT>XZ2t(kA#N9zf|+T*kJt@y5v>C1`R<#y=mF(A88v9nswd! zyxl9Z*nj_$nSnuS;~yCP@B94CvxEjohw*b^>ZQR#@}O50yE@4*-<%9x75f=O{_I)t zdkXuCLAKL!o*1LBOZ_NUD8eAyJ4s*iIv+vww=cPWiPz}=`4;8Um*3?J3%CZXxp<(S z5@sSneD3C-c3mrZ?Z3C+U{ey!cx0Ze(BGSYeUlb^F}FHTy+sdxYCw_}oPmV=oBVmp zQsEym2jHa#Hf0)>FAPxV_t*UlWkN)$NUsLemt1|yTAQ}>9&kTR_;Np>XOJgSBWxI; zhL;_w#ytBK8Gu6^tx6t91N5_fw08bKtW1%KBek0RObHDI3j#@g$r}omh9>g6dZZSu zJ7@3|v-;_JK|LN=gTJxo1EdD<%l>T_$IQT^^yjO&PO&xW^9i_FaTeP(*BN(U;89Ay z?qaXFNB7va2Lo@P(i~JESdK}#ou|G`k8363ja^Uceb9&b zhI4%v#c+K!-B)C?W%mNs>v4(q4@|AMGXHwH)y!PWL>+d%a}bdMyr*@Srij_CzJlNQe%Ph1RX}@2#jIVp`((p6~2wG|-V+SRH z_^Dzpm*$0WwvN84{wF!%%1sd|*nmKe1faT0U#OT}e)y}9_9d~LKK#csfgNZFa#-X4 zBz$Y`KG}mprq4F9;g&5%t2vm@!Am6rDVJd|nEit_-bi+im7i!TC#%%ykRP^&qymq9 zmK+9JFWR~?KkQeCMNJENw?ncD+lL#(K66lknZK10F2)CD#h&| zDiIJ2mwl|wD-&oV71&H4$+E1Ak$t!pDaR>WCHmybb=BjkMTXP4^LRZMbEm$yKDP$h z6(5_Gh~j%Qottr4stv;{o0r(MZ2EDDV~&@B$EMIfdKmvWlESPDfU8~FWDt4V1NN6a z2g4s6TWlsHGm<=dP*miL#`&;gwfi1CANFwF(0G=z?pt&*gqQsNTm|-O>RPL@JIQgh zcxgn+xWg)2=U8soFGil*;d+v4;8|~+tH$&1A|pght_k+Q1TReC-;@H|iiWh1;|Pc0CE2cyrf+@gbc*7i>}Y;+2@6J+sYAjhX&%TOHlrXj ze^%WD28=Bn$P-wTW*(K=J22!?8~%!#u3`0Pwv@hY+k2e)rTHzy{;uyOhMio-Qh(=y zy5--OuCeoHD;O+iMa$}Euv0Ft&ueNju9}gZ^Lv2tU085zMPaUGKc7B1$JkJ$W;Obm zmscZ-*43E6_ziehz~@RTMse>2s-w-K7j%4p=9+7qoTmGmg!tR(9v`&S$mauCl})_6 z$aJMW3;^?_MG~4M1a@B~R{WkD&BU3|ytE!Ul06?VPg1>YUJ2Y{H|^5CiRNCr!Jm(9 z&lwua#>3=z4%X7=-P#FMY#$QPuX?g>@m?=Q-uHzmMkBabm!bwL>_B`}U2iy^y&84B z$nk(plgdG0E4Yb2eglQyb{@z%xP9M@^ov3UydY9iRc?SPGI|cor)+fFW|L zmL2z3%9-Yq{_Ici20AT~mC?Gl`ZwJ(S%nb8fdh zaa$O>2)L`Qk^+GIdyh2cyEETDIR`E9#~*)yvA)m6J9K$7r}w;VrOFZ6ep@bzypRES z6gNSPm5gl9k=UE4j0`ls>Qdq~Uw@u?>}(#&Jnkmp$x!DQNKD$6nkrwglrPShqc}(s zL{jae=BN|-#iUlj#=-wc)fh%yy*K#h5Y>}+9shkeo z+`J+Pw^to`m{+@jtdv4rWH%grp0DEMxbRf~D7@sam>7?llk8 zCC|(l;DCLlE7w)To@Z%R4fq);0WXOrfUsMo<39nUiSfzHiv_Un}H)6j6Dr z`McqARfcaK9W~r*Z)cZob_h_}g;XQ#BxF`cOhi0a9V}$*fs=Lf1E#4@ha6H`!cyOZ_#0E8KRCd}hwsaz^Cfm_sh(Dpp#q=<-{|-=Y%=h~ea}@5+4oj2d z7kYWdrvwBN@_HkAJF_U^$#Jp|HPf_5S0$W)=ZU`D88&WStX?iQS)KiqfZ_2BtSNzr z=`H?HbEUj3)o3&?dM0)%b^_#mC`OH@?hjNhfM3^4qI%iEDyi)4L`!*8&c1+Sy zNXFBv1@BsDS>WzvUqDz(8CF3gr>x#*Ffbm*Ep{OyT5%0J^wFRzVTA*lsGiX~J42d6 z&tP7w>&XdoPOE5&?!g4VJ9#DBy5dbp@d#{EqD$HaI~;*3waxAD?3`N6l-_6`z*$VM zrgUV@M#RXWE}V|@HIl)+VqY z`^~gG^jFl^tS;JP2&PhfG5ga;lbT^nk=#cnl@8EHKjz{{rNhiwQ#YM}gpiGB#I0g+ zVE&Adyu3Uv+$2yeN*rpkv@gVH{5ZP=)@$+}JRxP-wJ2XVFA z$tT%kJj2%Wl9NLpYS~3aYb_>4S&M-g@!3le69;%W!;Tu9>~Hz-;m)G_(4AIFIDI*H zUSW5i3T>*#uoU*Dg_%bOK~*htwmGiXI1uvnECI4HHNBBXQ`Wma1r-(VbxFtvkh+y> zf<#DXUh$+Q%CbWqCtkV|AOUn{_0`pp-Br6(C>B0On9tj18BECJ97)%!;WbCMTO_#> z^PuL7?e28$1Ys7jC}h!kTti4|Ylrr`jp$Ipw)0IR`CP`bvRD&_$@%5`(-L{j_yrlQ z1NfD_SQG!1Wse|L|KV9t8Hm)xyE0%{FuT|N6%lweB<6()npEy1+}lZ9FeI8GTyH1M zPThIfdWUfD{fICBygbutzt+KMQTij%k5^x+`16V|1b7Q~NGUJ&{ox(MwZCv83)vx4 z%q6W%Ymx@tCpb;mm}Mt&F3AQPjX&m%J?qCv(|-03kA7;Vj>{@)kFS)rgsiCvGE`;3#Uo>JsZS-`v4mX8I}7-*rS2fe z*FA*yrh&lU$Z^zO#XFl0vFzc_8gAL$m$olpT0dC5F*=KI5TAjJwpgW&v|%65P4B8N z^%^m(pG&M0j@MC18!l+1(;ukM?3|*PmtN+N$77WPUaV5Qc>-|VebDn!~p^ZTcH<(uAsn^3n$7WjE%r$T5 zq+Z_XJ?Io4rlif+3jW`Wg_$jn8g$)(Fzwi;4=46q50E<}A!65@)FAcXoL%*+=yQgj z5*XAyBJ#gJvFQM@{a`JjL}BR)do;H%933IdD`Sb0g%IlD8y~yfr)@st{aO%bID-bt z8JMD`rmXRqLF)mmP>Frl*ru_>b?9KMMm-HWxNFdzxQ;~%A_#d16CiQKHp_Glt_<#m zyJBR{+fq!VjeXSdGL-#YSH*UVVHC@(dQRRfo%HFZim5}DO)t%?djs29H??6@i3Qy^y9|%CB1(oKeZIXM=wTKSc>& zi0=?gX16e~-xzn2K5fEQU=p#ixI%cb!xQHeu~^tw!HO(MjsQq~J5)_Dv=Y}M4_I1NLJ`v=`kgG8M@I!ySd z@2_1QjvY{0yb4|m0k|S7jqu#;0bWujrG~UuRJ~EAfrGnt40??XtFS5CsX@Gjjo^hK z_O?$OK9NI-2BiraB9^ooP*HK$nIoxM?e$6F!>6y_PW*m5HO6#!ao3$Du=l`+3ED4w zP(p(mJa{x6Pag6{{^y6v{8Vj&-S7q87Ipz<6cQ=0l=(;@2*u0=VK9rsT##ja$$Cu? zl1kUWSiIn3UrTB5BG-8G32(i4FToLYbCQCm2i3elBcc*N;;p;zNy5}vY`3{Q zs$`fL6bh9+kd?9bZ;rp~sRJ6K_G0&o`uN@+xTu=MZ`k2_z#2ODoHeMx!)*9sV^CTa zzHLJCJT$8oZHO8tU$0JyEA5XT8a2~W$KofIUJW6pqU3-JLCAGa+gAN3n?Wlj-_h#z zBKRi~BL25mFB2 z6O@-|Y;mNSgDMcy+|Llet*gE)d$1r-`Nv#Ij`JBO)_CLYgg|o&2fqRv>@br&4VI1IZ_Ta_FQ+QaA{(K!kq; z1sFK2hjf6$uXdTt=Ssi6G)b46W1CC3pfSi8TyejGg)PrLojKuk#L;}@N(d3pC~S|6 zkYZa{Y@9IM_!Pp<(#8F}H5>!lAM5iVp-?TCLA&5f`q*M)HC> z#;?;hkdNnYo`vUaVfid_!c1)U?Y9MAf8YfFseB(g_#NL$hl~l58TW;*r1R*;*l{Q6 zf=SJ8sA&m4GqwI9Z)=@YGOI6Kld#V4%NMpFX0&3q|4!1&;L)_r^%MYiZnl@I&S2%9 zMgyDu;w2k~JVR7@_e>&tM3WVLMvmfd=faWQ@T*!p_3`|Mf1eWn$dcRC983Bv4Qxu8 znbBU6V!pR`hNpFd53EX#b17jazGmprC=0r}?xi>DPS-5+tXV!m5b zOM|YxJ-@-dB5?x&NoqHlmZ|>`;a7jfM!K@T-Cn`1u>GC2PyTG2%~@sQfR>bX4FzoS z;V*k9hUvj6+us+t$Z;U}3Vl4h9%PLVwpTmr8*3LHk=6Bl-idPod=VRdKw!OE8nwaf zu4ktWD>RX5+sw4~p!uBT!}t05z_z7dPk`Oxxo~|NG`;Ihouv|d{dobY>Y^Dl%BWtZ z`J_DF*si(g{G}@8hTzn&aHwo>H-a#2rn!}_{^ijP<`!`H3qVqd`I1hqgwloSn>hB* zHU{sPIatCSWr(h$lM7;LJw{Un8?^RSj-?_jZC2fP#s~M-)Bh5P$l-T>3qwX(kd?u( zu4K1nRVbBLt?)2f1SvC}leFkLI?#$9+PGtEao(R;)m~KOGt!nK(;e%a3!7 zpU}x&<`6D)cUl!~t7g}X>LPkR2DeqJ20dw?MFIOI0Rkq)@3+5BApbn86YaGcHK9>F zRWR-oJ2$V>+)!9D$1iM!7D1VHF}insLBEAHcdFv-ZC3m{=D#GdzUWfz@O(0A&}db_ zD2>@@EADf}xl-dIGCT|Fzl>@$OSVNSwuWQZC%o_kJumn}M5$1*CxH;VC_9T_{+H)) z&ab3I)*kq5Mgkps#Jzha4PYGDO)o+hq5f8KF?u4W%@Ut#xZcU9V^`?;9^}2rIz`Kg zu#euuQ7x&8^)%w$=CNL@w+N!l@TOM-_0bac^~ZyxgQsrdDBwuXNsD@5T2TKvu{5`h z#dUo*Qk+ogfWS}W!kfFwr(z1DczxqUf@EhNMF_elMU)Kh69%ys)>(fm$^MVzo-&nQ zRQuAIIfO)fy2IvK@{$>p@V54q%r;`Bt1VYic0=!6CoK%E*br-X?5B%MGYz3&GFv@U$qYC6 ze}8~t$MXw?S?O}cB<`s^t=RfHz5yCgD*0}_ zZ!8*?nHFL)SY8s-4&gY{!ElBOZK^JEg-3+J%tx524xCPv((6?Vu$~!Rlv4!*Y_ir7 zHoy>7U+_XblGnE_f;a9B7G>$K9eVVMj+1AUrQ-r7!j+l9Lh4Pa&8px-uR)8JqW#fB zMQ8Ee!kms51$xOHSLARPmv75gCh(s&HMoxyGnF38`u(FKrxcSQVKUuIwu^INrS&&F zX1NzR^L;e|+;i3a%N51Q4%Cu}zP>&TOpz0VFbqfIHSGb_CZfOpCLT*Hu3xJ{tTp!X z4-_v~D7JS3EH(=Fk&-<-j}X1smK?EekYzX(A6Yl;Ro*?{z|x?hE`2Mu<6VVi$FhG% zk(a%$hRtGhaf8NqYb09Dl^wNStkUZq>7)nukc&nB4Jf9SBk7Hbrs1@Cc7FW*wmb_* zjXrV{+)9I{7=(pc$3%9mpDuD7N%V3pzsD5yhpE717f)}4Cz!J8rMxAZ6b1WUA4`@5!S$JPkkCu*pbjbXO=hoVtcn@e;p z&)@M$>;tcUVz1@cRm4PkXm?o9eK>Mu#zJLscQMStAnl=Ktv{kD2&x?2=QQZS%c@i*gVJ!lTxpY~6F?Ts^x8do zy~41l&B0Ly;{~S5-olNgA~Su>3I=L?_i(uV)HY(Q5*2-h!j%h)`Q_ebme z<-Mi+;VpcH$hm_`;+n=r9ih|ZKy<~&?f(W)J$`>4`ycl4(QYr6|KhB;dlsBrzXZ4a z(G^46>gmH^SmJ$7FxYYXN&kEOOFBe>Ro>C>Z>Wk33{&zV#E2anpBTv|cHZS%$qRG` zZJzse)aXiU7SYA8BX4d5r!HD7&b6jX-6k`#W1gH4VV#4zN(GmwcX;VrV>j+aEe5X_ z_$>*NHF~h-=$;|yS+N{Be3%1F4}1CG+Xb1)MugvbqdtB-PhdI{RyfXyc>BxC$t>zi z^UuhbninEd;W?VZO!+;77OYu>9EYS*7=&-WL=tT9*S9J2D`Mi`m$vGJ^>h}t>b%z6 zA(6PMP7!Vd+^t0c#aIxyj$69vod6@`{t0veUr{jV91o~}kta3w)Ze`fOiD$;tm*E= zxO>~)#2H$2K|w)%cfS3k<~yfJUHtMuP@bjVQ##7N%MG{t9=zRLEC5SJYYO8d)$&=IZaj{x-1H#i^4gU^c5vEWR8sVEZL=br#`V*UOIn?ChTMBAeU%gV}d zpv#e~_fd&A#?;N-9fW6tj^K3fKb49t<-M?T_Lt0g`gI;vu^WaM&|b979slK79XTU; zRSQ?7_hpFsZ-J1X*huGFG}E^dyi9&{f!z0YrKm+KUvozX^U`@TE7E&`0Z#pSOgRm@ zzyjY8dHwX~@qp^pOt&GE(FKK0tC(3T*OI@>##_cDZ|CUF+T^GnbC0f(Xz-d(VDA`5 zy0xTENMKU3s&uk4AHCwy2t!U3JrWYA&jPtU_|HAts*jEp3#XWu>^cj0ap`R7;)$`* zad9y%7FN;YXn>8N-h+M->(UM~t3luvwbxB-;iHaoI)*LxB%+y}+4^WKH`IA3h-t#L1v91UwiWCLF$sabmI$K=4g$0pfP<+(`=`62z zRXUp01*iU|hPj@eVFqd)nR+!yKe_t~m>duAqi3lT)C}q8x2oupzO-kssXMtqdSE}1 zsTc*ko7CL&_#)Yq4|of|bgg8GeqioUz`UIK9U)`z2tQ>ybYn!> zFQd>i7DY4ul)r-c6#x`2Ud%xXQZoWOGQ*AV{Gm|22GAIdd3YU&gnT8I`+VwCuBSF5 zAA?jFW!`irDH$HSUP4?BVo%CrcWAr^8>m!`m_EH4*=~=KX*ii&^r=2VxJnurYT%Nh zyo_yZlKU)cg1Hx09s@uo(N~|x_x6(@uleIRur~!2*<4>2Z``Bv_|WaptU4MW2|TSt zfp`G9-g%yq)s*`fs=dP|;&qW>km7%aGpqJ^AX{HD5zu2ehom{_0R&B7*B%IrI;4!y= z0ZsF$r+}`A5vdNcwB_k;PfXMDtn*F?;asMDdBN1Zn-TZ zc7cKZHQ1{N3;y?gHcm$jQH^~nJNZ|RzggQD26XCJ(`m1z{87iHvK?ckrLi8E{Na)) zx&!AN<~miqg|3K+Fp$GrK73CbR4&^(ZLAC@`|8&UoVU})B?QUd-j(IYUtpdb3Csbj z*h)^`)Uk7cUu_0f04*P>H8v485@cX;-InKO3$=mmIctughu7`aCs3HI^P!}AH)jqsTl>G2G*c2+hM`S>1)ck8xEuk zS^1zJ!a}+QGbR038)q+b@VLe(8}ZaWgn^^v$S=nPBp-HTJ1CHI3KCLWM?n!tLyP7T zk=j@uXlT(gGyzs;A7gI&l^{nfNQpGc6%Vrqw2v-ii+!p3X)GJOrq`rqr7AMB3iP6D zmUw-gv#OZ0B_`g^RWK==n~r)^!0m2hlGOh zPdvxXnkVDKz69|?Lh3*PFb--&+sEtg$RzydUm&Gx zVr?4MWz%cP;S0W(YbkewtQ>Iypp4D7mjVPJ$~z@iVo83NGU&^<258W@7F^V`&0}$X zQNTQPp$vYkRr&845Vsu}z+>_ytIn^x??HAq8mm%mcpQv4w-BgPG76!id8)g@#+g&& zOCi$i;)8mva8NX2vs>_fy;|c>$bI!GDMUjC!~F47uZ@Skp~+$TGM?*|xZ4RNkS-n^ zCdFt2LZ?<2_RQc?04Vd47n}Kd-lfP62H#bsO+N@IDyU(}C>eLmo!#)S^UYXOgSD^6 zi-InmHnim#yuVSyYg~IX@))?|FYIX!TQDAm^p|7^^WUWclOBGw32Ev@@Y(xMrgkj=NsLekk^Tu=rbG2t5oWkEx`D&lMO$)>j6w_^=)Lnb3c0hW-iT(p067RC1v(^57B) znc)LMldS^a@BUtY!jZxslQCh%1A38L2hAqZ#{u#CwX8yo1!Es@Nds@EKOE!8_tyXq zW-IgD)6iYplG?xemznym%_o*^$z%Yz#97#L6(s6PcPW-FyK-ABdy6Whm%34-R`LIO zd110W^0tgy1;aNCKLOY{*WQ=-zsgSCqj&rnC(qS|dhi$*AN-rL>Ca8i2#OEyuXU^T zmL5#kGi%GM(bOVFM}!Gx=K8D>lGsf~6iYN4s6XnUsf7g~3|F=0zpDP{z_th)qh}}X zFxs#lED?R*(|Ri|9HZ436w?&sksi+Ejbddu6U+qr>(#vki4rtlZ8>EWVujiBg1%_>{?Xzr6lUW{AB)eVBYY8mNIG1NZ zf%}h@GoWT9smEC-sl`P03Xqwl#TOk11Rt0XhiT_-W->nJ!{Yd<+wGX;WA50Z(HEFgW zlT9)MAlLkVJd_~U818ER?*|7c-_w`#I>iLfo;9MNq~vC-lRm!LaSc9l<%{WNQ@4Nd zxOxZZfNMD!fOhha(_vywv``K7n^#v@*mPqJoEGL6SfWw`0%5%e>7%cA@j_Rq@WEk6 z<75FJNZZ1?U=)U5KpMvO=AJHDX_&+kGn`{Uhh*$_^02q@02QbMJ(J{n3mqH=H=c2F zLY+gF1pcAIlCurPf2!Z4jupEw{MT(CD1+un7C@{haP;eC+U{<45`G{9!Hvh-WOXm0o5q_1ZU3 z%Fb}I(8?_LO*a6Cz6LO_&a8BrMBW3xGL@!#tc*wEi{MJ6|H9|6SR>huS@m#>jFk+U z29RVY;DDQ=FtFMa@?$C$VU=gov(qn8uY5oANxf@2HLSa>v2n5Nk=YtynixZqH7Gcp zhb_Ss;bo0?3AtJybCsRT0ps~PhVE25jA^`!TcMZpk2JNR^mWA zhZ{~5kZEd7a0AEjy?;j9czBlp)`*~$UFL9qg99T`Z^^EYQa3Qeqa&s+E<|DZMB5D9 zV|u#&@J0cErwV|f9g7x9jojz2dA4)KUvqYLE`YazM_@fIK-E52bi3v-{p*I<;^dXJ zVS(Hw$BIMPtgwouQbaLLTX5%vbx$hiAPLv#n)u`_t&T`hiwXR*I>VLPu*tUjZLsHAvG&K zr1`g{PzjN1l_6Edo)a-m7$CzgumVZ(L@yK-eWNt zAGhHCI$rm`tQ2vG^5RQ3ETPv137iRro8^VMuLT!>C?WKWTQ+jVhfLgZoXSVQEQq6V zvL-~;Pf3GWUpxt2TAhNX)4~#K!c^UZYj9A0ACr1`U)2tR^Ji|JT&m#=JFS_P%^7=N z1A!MuSh0aY5A(0rA7_EIy-EP1Dkm&aQ%Zibrmc5lF)wkLUzx2qu#mpXm0@MlLuFLK#jS%DGBPrwCSk5&becxfBKrUB78$ab!ae>N zV(FbpazF}8yuMPcv^I2qMQ-Ow398ZwGj7AM{gZJUqxew+Gr3CDou@e6J-K$`u#lj;{DIWt)4s|{S=?qGSbC~N^*UIl zDp;P+ubRywKrLDTiybLnUEm$Fp~x3_CT`F)a3WY7`X&~5TjOuz8>&+#`BJ!6!ZKvz$wzL*I2e`#+aqpb!$M2V44o zE5q6c_QgZgnK~N)qWO^xzVeR=x1wz*ibTw7JhB~N053p64rHUE7NMgP6Dr7EcPX~Y zEVmQZwwjF7hLu}Xs=lKnZr{6^^6gEOP3Y(G+LyN)&4k@#G+;)9G4$ZEZ3UB}pSSSJ z>jZ!wxfhlKY;xWX6|i&^ZqB-$eZ#54Y}Bej&Q_DP z=!4YEr_ld3hh20cV58$+`SJf@BVhMo2W5D(npC=j=bqo7 zLlWEL@Xv-y5bzl>SV+-|ctFk6$zVJMkeFNhL!*b&n&QLB;rq8;o79!tUcU*R|JVTr zwA)zc4>1b6FIw7{A5d)XQ;|b~v!r2hJOFKyBm)VdNvfwBfBPSi^cVq1R9hDyU;2I? zf4tRWvIa1Y7}h_nw#3G6;XgqtjM3|Tl+d$;2~-db8ekO2a1>gy&TA6nv+we-9{2_l zW{t{Idc$)*5(gkN?F&~z-jcH93lU7P|1Dvjx@}wOyNm2mdIURdqyD%sYf@QwVBmKK zl%k?<@r{3gKtKZ>IJ#SbqUbhYRo^ar#1)r)(mhehJoJV^o^{NaeYBFs6N94vDzLcL zRx5*sHFTCaIH@TO(~}u@=31rdlzPd}$QuQrT*hAle&l5C|6%V-;Gtam_jOuSNE;H7 z7Ai7@7}+Xq_Lh?+%T!{rlkCe>#ED8J%P`VH%36&j+X&eiWz9Nv$-WI^%*_8DMMFfV z-t+$6|L1&OpErl`%rnn@-{1ARuCFrjog?PDbJs1=d0eZTCT*5;!vwF^3tHT=%|FYNt;P~n&fOmDc;YR!%!;9qdczXZTDC*> z=4`UzOt0FOGo!r9eRK)kMk#RG4l;XAyP&YR$RH3Ri%Z^FNr}_zO&y7M@Xbps{yaQ1 zvYede6m9p;sw3D0<;Q149PWYBU16I32L@Al%v z?&PUn%q)41xdyd-M){WtF{)ZKtu$T*s1!QX8kZ%`X(TjZpAZnec8ErEQ$}+_Kija( z=$C-*t)lhx+Nv)qQ*l*}fUNWxG+7J2?%E79wFJK0B9n-*fqLK9jEEqpTP zSq0S3)elDlRNUu0s2_~1WLdNir(|ZdG!Yj}8E0!d9h~%%#m3!ZGwAkCE;pbJzFS1k zc~{5uFxEwtlD=qND0@1x%tySJb)g^*fSDV#Cl=-Qov@esP+lISBk00R{~Y7x*dj^A zB4)`^{!y=E#s_joFy3ofGu%|3jWnA+$@5$Z)y z=u2y^PPu3n(4e#@vc;BTw_& zF_5MZJiEJx)2qM+)F*cp*X#NUlH!~=du-XX`YQ1b@5$NgP*#;=itZ^k9iL`k$!jh~ zL?Ez*1^((a2T4w)UVDeO!XnzhNo>D?WJ9fZ!T)nD(#MP|kQh=oVj`1D1yiZR0znpc zAxG^tp-l`T2IxJp)P|QyLmcq2NMMAjK^NwQCxo0r$mHe{1=KLn6I4}uOMo?sr*_uj zmH&uL9eHRZn?%p8rN2s|>xAYY$Tj`hPh;@!)){s4gsctFHcvlR6qQh~pJvnFV=u< z7y6sJ2v>P_Q41P5oWkeQz)&GXiMf}sjn|&U;1IzT?^p#LA4mCE+_wzb?xlk9t;Hp_ zlaY0cqcF0FVxDZWrME*% zFQ&K~KCzmPX_RGO>k0_x0buG>sgB<{!`A%9AbejqbH?Xl3tqk!wqX#4(-p9xwpVg2 zg#+G~OaLI|))odyR4RfwWMn>=_gf`AZ$!Ca8`xg)?iYC7E%I$wjF4MTlz~B6CC;By zx=d6N>N~1A(lz$Y1BY?EFO|0KguJwXJl2vo{Xy5Yin=-z;JP8b>ksEW4g~E@6LsEe z4>&I)m%w`-UK|EF#A|mXr{FI4bMkMjK>w&`efWy1(ym7?I+r?IR-d9~da?&w?Q$DK z@^lQqZxp$39Jo=pApCJz2P#bE#}FPD71H?tx!eb?FaX=Iskck6Tio4C?(r5q?mg(m z#LQgNu#-UEM(2H^8oIGhaJ2 zLIzS@gCmO$f}V1&^f9|z6B!_8i8+8jyyzeRE}1v;`nz_cpt`f#A?yt20v_2mTlqhp z*}vwi8Yej6FOt>@lFDID@2E}gNX-PS*@l56$P$K)IeD*A9Qurs%twFD!WJ~ z^(j@gAd|xHQ6L0#?c+Tk)U}51qKf#6k&DH#AP3uvXj;cDMj-^tybgYZbTvDaodRZI zbd%&)^99EfK;(_wNyW9`D&KW_jMs@pNivDxi$RrlvUdkR|FTSVJJO-Gb z`WC=|5eW)9U7|_1`59UJ*S^3HojD% z`;gm+qyoJ_9kDtz2SD|vHI}~XBkTuFlklzcbPi@2=M#r5Tyn7e3+N z<&AL3$M6AkTWJB^9^{+!s+4L+;n#*o>{IX6lb4#rIgcv7v)?&4)My6M_}v90yv206 zUAo4ptsVi26P1Ws=a!ukUnn8~fHhf+nBs@XATnhON(-VNVw`m?<34;Sq~WT8v}m9{ z^y$_SFUA#w#K089&a(;m(PrLJ(PVL9i0%aw`r#2Jne6wVeH8%OS12}p9dq80=ucUJ zj}}Uw>9PQe0*d|h`*%1RnI}h;O79JhdBiR86!e<+LI6#J_v|)E;T(1Ti7w(ap9#Q! zfGKgVaT@Y*W&%86fEKi<-*=MeGUn@;QK?7&D_GNfq_EAMs(6#Ys#t@kRY}9*608m7 z=L=0wTML)M3&D90(yyzw>hN1q!(x3Co;(mBlyxPS(nVZPNNXW^I9d=L9;A%yvP)P* z7w{BpMI{dygnrpFa2V+>Jyu-`M~?)Uh#K2g-!ctcpH}iHZ~uOY!blB=0KQ5opL*Vp z(R;XV$MZu#dEx~8)KWOG#2qlh&xy%g18jlQXdD12oBy@eXI0(PmIx14idp>Fy2i-{ z-HHrK;ECtIrB-j<3V5JX=v{APFJX|<(PH8NGA2+cRM)SUVfDbV(kM-vdN*(jI(9Wa z%-?D|Un~dc(N4<{lQ-($=&`7%Lh3BB29K-bh-df8q6SknX_|u0Z2d{~au@UwPgW!q zD;8*m5qDBPGaMJAtq*iW5SBnDHxNwWQtK{-Ql$bIbY8yKa_F|I&)INF(~ZMi@lSKS z83iBnEGDKH+}X!~YS&fz8SwEY8n8Wl^}}-d3%4`SLi>_0JaIu=GZd{ov%EOpSXj@{`bM%gVZI}Gh7nu6 zPekEQDapX+=CW@V9TNr@VFDLnEJE|IWJVvafChbvF1#|EZ6B4U3!?-4#Va>a@Z?Cd* zaz0QO7`#=>!~?K8hLg1|*BUQLy__$s5daaIYywY`jz71X*D3Jr%VkgRCQ`xZWEO;K1~mI6tPiJ|rwDm-Ug)2L^_#RypE}1p+TdPx+r~>c{c%8NN#gr0 zcypaeD`lfuL++2OzHyQ z+VfURNk$l6^7YT{E0hX})&R;TBYlzKU>EqzV&7(T?o_iH6l0BSRfpdT>^;eob9fp2 ziFPJB++F{ss>%E|?u?7@2h&CkmP5k*RQuta5e8Wu!7Q#b^n@)m!7Jx5J^V>s26~sP z{-M$ZJPL8>y0s6B{P$+of_ksS!;@Bk)#ir;QcO6MO+BGa=LTSE-#yT^6rz49Iu)NZ z6o2%PVZo&%=Dgb^J3Bkki_Vwl@s(f$;=+KOvP0p1>dTi`se;#-gY)^VeEyq)Ha*36 z4-fh~)?Rh_>JTQ2-e>RWnEf~VN{oBoETT_m4@j?zcKi$k=g$Ur+#M0zv?&pB@px`= zT?SGbi?WvJiM8kUKbn8zW4 zK$Z3h&U5OwLS=ovE%MAu|9W5b;M;5ZU@hYLzplmqk=*26!!k~w_^=GbJTI94DG<|% z?WS)Jn!D*!jR+psZcdx{*gSJ z*6@Ksd5YDE(TWCZ%`m46(y)Uv)<v`0W@hr1S%zmuH%$ zj_Z6W%5p62F`MbDzt6@0Q zVPS9di*^wqgK_AK903wG(Hil_$SRP{m6|YVNJ%*X&caNCe6G=p7S#Ppn~C#~%o*!dIM4v_N%TCT=Kb!MC+H@aazu4v|BZC%_;ub+102`=5H6Z zy=lH9Oht=;soX+q71BUZA z&Xc)0=v~1B$Yf`ZJEWm7nSK1KU5*1d0K5rYry}{m&5)|=7+@4FmsVl?UEA9ewBiBH zS$yKDPT@5D8ydRC2a6LZzALcrj<9LRHuDz&>@_VG1|S{ZOszk-t4ThS7a%{~Op)E! zxdwHRMjbE<9HiNw2`B0%7>rgyP6oU)c>J>K!pmt82^#<&eQLpJpo_;`8zTS0HzCV`@TL0K-?^fC=%NmVJ%&!^l(81-0kg9eg#W& zUt@tM&+{h4d!7_Bpckg&Gue4qfeiq&*(dMeYh$?Mu3YgKjWK!H;rU{H?;APWe^R)C zW(~m^MuIkj;}7w3f$tl-08C1RL?U$zZ<*X!F=8CS;ToItVE&hwrfcv_ST7zZKgfLx zshH=Dq(<)6$ut}gVudZJP$}x%tf8Tykc4v0RQotWD+FNx`X0XZqH6$Ll16|LNMW(E zO&wDjJNp6T`W8bUjs=3wXFiqAh97fd;wS`gMH3(x6TOLL-S;|Q7Hd#9zT6pj=2H#Q z-Hh0^RCEk!1W>^1EfkEvyU;@vCxrNb9@oiRKqhbE{Ivp8%=4{hwiPfb0H1_b5OdBg zKY^HRXbG6c>zM1K%~9r_-4;wpE&NJh>G_>6_iLiz7^E@7QlmB{5VRlKPN;4WF7r*(Tzw z_OU2cesWa25+fs{KTzV(=n*c?ty?{C{g)`9OW?E{;U6RJeA^!W{h%f)g&k1(Q7`fi zRa+V?!fb+SGmZHI2rS>;9mG~sanS$j=KP+~`GfQH%dKQR(9RJ`y|s5L{t>R9AxbzK z+44eaXY3>vfJSxod*Y=p@wX5!r)`Ss|4@nYXTz4Q(1NPfU_rizMRZwIfI8GlZR9&Wrd)$AE}ji+0XfWy-7lyVw5TxhZrtb3rp|< z0ot@E$hvieEFdTUXnalAzL z1zgn!14^9xMP5pUMa*KltLAp;RUb{BX6tZn@=>^4?p&l(pJa7RNgO0*iV$58D1J>5 zU6;Z2^VgU1P|&@nTPXKO_y@AOdruD3N3_&|Su%H?ErXjOh&4)Zco>iZ2Tk8Y-Qa_@ z1@$_HQ5EeA!6Wmnmj<7TjxLsUdWy4*uDr6Wp$kZUyrfnm2nb3qB{bp2erj`>szX0m z*XvvN22yy=p2#1z=h(grkzZ_!0-y?MX=DhZZTU z4tO|$K#T8&ewGIdV2@L!nW)4&nGIBxA#m*@3rzQT>EWP#w>O8&Arg=taiMAUxe(g{ zgMfkbKU9EJy1j$&0wd_BdFe>dFq@{zF;`pxRN9U4T~&BVz0}lHAPc|GsPO)F1(29X z>c$iY>?`1O9&H3PL;^lZ!8y;{GBD45vHA11mUlFW^tmn#wh(Xdb3#P|ro1pvteETa zPKJmjmq4b2zeu@E+HGd%*#R0{ybEhW$*th3Ez(QCHH zMQ5?Pg77NO_xiir_xho`j$A_f4*C~p;J3tT6xc1HJ=myaStm0##M0i8AR%G32O061?D5buwwEMwpAX^!ObLj6>yUVQ0L@Z4=Y? zO9wHb4F+8g7oZki^H$k!?<&Gc)ZT7Q}9`DP1bvW<{=2&$xWxUwE*s)Ex zQH3(3QUeRFOv%y^-a2+jKU%Xx=WxI=zmh@!PSqr~gItLvBqk}o+^uR!X^-2N*tI+x zv9UFO;Up24n!tS_#@aqWYG(+PV_6oGDNBRhLpadR16>r9y60ZIptoE*AN$6E5!1o( z&|b_Ldqi?++cO*-fB9L3)gBVuJBNMXQ+#A9<5?7G07AJu3T)~I>9G%9**Qcau~EC% zUTqW*57?=o_&$ZSA%%X2K&R16Zs(4)d*0jveZC1P+Z|iBa<71mX0TOHd<5j(L=1EN zGRryvhc(rat&(k*eN&g4R9xn=6fe+m6*Us~N<`^FsE;r~-ZG^rQqV(~!vig}9xgh# z9)4X7Wu^I!bZXjC6}Eo4RLZL&CUBo+Dx^QhrQv=M+<4(DM-{3j5o8VeB2F%E9X7MsNEtku#hpXQaVJkJsPJR;8(H9)kEpwmU)Dlj z*vvxJqa8BX=-k6#H+qO`F~UojAXLOn>F4%}5%Eq-q1N8qRh&MbZbS!@jK@iUeIk?6 zZDQt$8#Jzf^_)rq@`vL`MoauKW#sDBJFwX<%!;twOtyi(8cA-gS0eGyf0}7_Vbg;o07EVb}`BbhtezLz!y>lbh42i>1kg1Id`b=z&bvGLZ}8_f}Z zH96?6o?zGuqPzZ@5rx?gd$wqfN_p3t)kz*`{i8!S3bhhSQvVY15)5tMTN!l)a$WLr zD3QZn`R=jkD+t-*6$80Sy}f-6r-;^u4rVg%bh?K`98Mj#5M`v@p@9+(=hyPS9>S^! ziL~_AwGk9>P7y<7>#!oFwBoK-#i-76W5v8)!Yv<6G#nm7J4fGk?ZUbi4m?3jrKV-ND0J7n=uRcAc8Du2 z?ULiks@n6-kDW%D^mN_0DQSaL+%J>&{%)F0U^~QVB&vj16C^4|l1BRp$3?qz$OKAa z?;cCg>%IZ@)M9i5be&M;NP5k==)f-q`+J`^X|gQ-+N^Tnl%ISF&S5QqN9Q=8%IC4;`;@XiS25i$&WRD;I8$ukPpt^#tax6 zTMn|qi*9{lKqi`oI;=6^(v~mQrG_~mn2{e58$xaN(&`|)JzUpvFR&ihm>!DnF2mP5 z|CL(ZVSo?0+hn}qn+w>96}7=<9DbOr$Qu-V@lq}>xoFT2Lsab`bt?x3ItN!3AkS8& z4+Sf6!E>M1yvE;WfnQLJ+14pcas~n23S=j-!DL|(Vh%rzFBVp@;I+-tNH|gpxj`Gn zOdSf28ZEd*yocKk>`P7>w;QaAD$3x;x*eSK|N8Y!k?V_LZJDIniQqur(F=X%u!Sg> z7L1)7|9Qj>x2jz#^) zw%1LH`7e*c>_p=e?L2>P{II21o7a0m0YsjM@ZM)?_lJ?rG9dK%PlgfEXsG6qpN^7+ zF*#Mco_0pc zkDw9!MT@QOfx`ph=%J_32+zsThbgNHYA!QEjhC4{&^uB=Z2nn(@PPJ00&d`D7IM4( zCs9#RG433EeBI3&*&<@^RLUPD-WPIDG@UE=nX($1q2f`{n(8{{9C!Ne=IrcjeWLZv zr%(C2YoH)2`I0dJi(zckt_8)|Cpt+M^pFWYNr+3V7HR8e&9BUiEj287_0*rnNOIKF z{`Ydqi&@AGrTlqgifP=y_#bK;leXn3w3?ZRe}D9L6@j4O+pZ`)jas2&QDO3fD8Etz z74-z<{sPBnQW%ilqd^FA9^~;~st(8mD1X+VAK~j>@L~sp5?M~7Zqsx7PZ-^K_s!@n zrT=<9wJ=-yGwEO94s1n5W#(;fZ{X8)12~$(4D+?*keOOyGt%{`Ejv|Ji7G*-a=Pb7wwe%?!yNkya{Te%JVg^j*qhxRCIPnNmleH~HPZc`s3a$9-Jio;X{B^kZmLQEQB}z!XDZ>O+*Viz=0t$0Q z57(cfO@1V^s@kwR{<)TZc_D+|j7$WF1Uqtx4Q^LzTAhJqX|J$@`0395{$zAsGo#%V z%?cf0{BL#mTy%Pd^Rn|4=CO3*PGMSD5CuYnZ&`NRPVABxXlMLi#L(HRP`ZJg0$9}# ztFzdBx6+s|kO*toPK)3*Q9ezC0>{a^!L42pX)LV9@Ffp-%zpMiVoQI<^y{yO8+CRz zX6MNPpT4(h8FxpL@KFJeBv9uPWrdpis zPK=Hzck%0FgmY*QcfgP0KUI_*d&MXI;%redGmDDCzc6=^)B@hUwhy60l5I&oJx9LP z8YXkN4$>wrEkm5LNpH9N$B3YZ`YJ1T(ngC9n~W{t5XF|nf{P^tIXAcQ)EY@dN3xku zJ+3D8~cnf_pUO zAcL$bfs|uGDMDi-mHXg1z~T0NnT@oloQb(yC_^;{m8FSDkO1UnF#$uKjk6;Eoa9`X%ff;v9h!R(Pl_S%`bGThZ)*irV6V?r&IJI+450TY?RK z_2R!6m;S((TCb3w(gpZZexI3U{{E19t7{&%r{_hCI%{)AVEuaXcnp`P7%?vI>^NK? zHvpk5$Gw=2E;+iXtE*C3)^Tv?&-(lB{Uc2^e0-3i2jVkI!I4=S>M-VYgtSq~BjhwY z%$78}L2>y?OP^@iVmd~brdv&nbXRYH4?&X%u+!bbT{JS{r(EeAJy>}m{$B? z`TnUTn`k>M@OEcpK_fsI*VU5^lAl~lCW@6XoL9Br{WqciH=&>E!Tp=i|C`Xy)}zlB z{WqbX0h9PQq5n6bp9Bs3o6ygS>`^uNH=+Mkc+I~F{V!l+{!QqAXF{K-m)g|j!<_oM zj>kM1bD0DhBG)@t${vs5XNrMF*l@3}ma;AHDGdndjoIv_c@Ll#x@BdoV{bJX@EAVgdj%|1Z&0Jm5DG$0q^gnEnG3wrD z_n z47(v%{I6?S`cF0kX!ix^M%j-Xd|cXWgIJHPhXBW}~ zn0-~hr19SsQh&6?zw46j;WbDelS#gJt-KK07IPwdqp#I3n6@Q!o^7_#hpV_Y$z9o0o`~>R!@(!!e$Iu9uA7Wl< zKlV(E|DQMxzqx&NeTI+wFZEq%7GGHVCxM6C`LXZ(b>u4GV<%4sg9K`rSK=GN1 zk9icWt!-4CvR-dss~>vS*Nzxg7s#sMHR=z3wb_U)zTJtDj;;$dgwQYvrv9SR({>76 z**H(LU{s38VmcfD;o&{^D4xZD>=)S1|JpsU_+-+{s|W0}*!yPZ0CX7mX=cVXox7!Zr!2Dw7YBPadV7uf!H3B_VM zK+>2ekDxm@fBG=(sm#XpDBp$c0TX)6!K9cQadB~@!4f2lpm^;Yi>>7LK-{>#ZGRS9 z%`LjoLo*hjQJKR1F^-lKP#}P9sdp-t8hJ-C=kDc8OzF$;Y`nqtFs=I*oxrkh`N$VL zMy9u+g4}+y#u^H`Gbm(Js2jO2?%)a$s4cQm;84ydjh+$6_u5lR5?vM z+)?qJ#b8={)hMZ$u58~d&Nz*p*rORbk|1GXI_9Jgfg5Vmmm);%P&{6y1=R|wsGECJ z??)C75%-!|@Lr7WbxBBH5G<^A3(SHSHw2RxCh)cK$v_*BaLgKI6|>m1W#pPEX1 z^u8xO!aBI3%n`BFuGRwpS9BpkSyj6>K7mdrIGwc+Rgkm5zxGQNa7OaF4FbNOE71Ln zc49n7D48EfZFVvXDS6P5Pynl!&Nj!8hhuQorgUw9-!wgwo)Ki$nJ#+D0w0hUw#(As zCXf(=X3l#gM;jLR_kZ|cF$x5c3r6u^Xa%WJ0@EDV2=tLD64G25Hi!`lD}^wEpiokW z_|c?J1ZnYk2M2x(X{fZ=l|sf*3XAwt!Gef8pP)OjX67cJ9=oO;VSxh~DcBppZ>!1C zt|wp}3CT`45pKM?O&lQ{xyaiJiZzoaKzTI?-n#t+CEQ3wz0(_xm&X(2T zo_L_a_>9fu!$s1Rei^dXs0yV+tR{z9YAC-ipmMyR@1rha9nP7`+JtSwN8#+H$>c;! zhej}i#3K#z40h0Z`146eP5Btga2jScxjbzQJ37iuZbZ}%I6T>j#WYJ`q);*lX-P0` zt??!wna9`q(8^T6knA#M0+}nc@jlMWk0~lryW!4dzXOaZPIbbKp-`xI2soY9Jh`dn zpBDHA7v^vor9r^!q^!)zfvxhA!gs{08yX|AVr zEQUv$Ra^o6SpE4vSjkSk+Q!E7jx041V49oyIj5GbY23xZg4h5xA5tBTl-8(;Y1PcI zajNXBNkkCMaBDr2RUDhVO*$&>zU~IJSXp9wUM*sjS%w9WU!_;Wct*C$478aO27J_w z^Mbv7%?I8P?w7TZ#+ukDJE_#zMh8qg!i#uUt#s zl9R)i$2jkEY-E5L!z_GQ;R8~o0f*%B`F0kO`?<}WZ?+HdSGIIh24K>)tQMy(t|0U( zv%vuqw>bWYhA^MZt5eP@7{wuC8pT1k1K;Ec$==chp$wev9)Wsi^mE0Z!1n!^JC>P ze_95l-0uYX0@?!MEef2aO^U~>wv`C&+KqjdHjeJ(B%HzqkLMxSV6EkE!04mqh>sUd z15!K`F)612?j1~>>CHN1lyu8sBpI>a84}y3011?dxJOQYmW8}j54nf6X;j#EE$N?f z1bdPnRei(18fGE!nTuM=&%`*k@|zM)<+ib?opQ8d(Fx6}8sd*{VL5-wS&qFDwG|f; zNw7@H4Gv;eD_x3AIn}eNcQmih9Wi>Wakm-a1HM4iEDRdxyglfyT1H-Chu>ey-k1BU z;K3%`i`An${yA`>r+pT3{h?E?MJ0}AFL4wSrQO%89)cTG@OTdB&S|8YG*8&TP6Qc@ z^6AZx8PAl-=H9%==?%zleyq6xb`r_808kOG;$}lg=C_W#~Wiz?CB*($Nv?0M8CB_ zeo+|!nf+wI9^0hYQwqjq7XXtUvg3a5cHX5AQuZs6s~me@f{#(`)HB$xz~5Q`X(f8^ z!^s+taoVv|gUCv@|J8WafL;Kij~=mJVJAV#Zo9@{?>5Q3^8S-V9+Rxh5QV>r;e6;`r~5)1WJce#T)QIg@goyhjHgN6whv)7OoAG2a^(FirVg6~4l8UA+nYpA!wFc`qMp`yZfWLJNOTJUc9!R@|OV~gi=sJ}Dh zb0vU7OgR&%EkzVn!~Qt2XdZKRgcaTto!sLAyZ%Pt)KMSSlL@k51z|e7V1ERV=@#)5 zN|aAtzSFV|ro2n$k;bl^r20kz4!iS6~~#Zoqw+-k=K2VCe{ zWD)|`Ty1n341~CXsb>-0x(qaY;7Jz1^u%gEqDlK`5 zTdbvAcLYZ{K>YZ*C%N|yjsoJ2-DG%$6G@rpYnlM2O|-D+Upc*6nmeuxwVdByquV;)4W zXfbT;KN22yV*t4<)-P>bGec^mPl>XqmR-lTF|iLE&)p8uuj3p9i7kiN0PCgbGK^&| zlFuCBa>X&{@HQ|Zyw(o4>}vVtjlh_(BEs9c=c5l4okrzFV0%(W^cvIY(?Ursbn5#C zMp{Z$5W7KcHfZVeKS)Z=ronAau)*F;=MXcUL1iWA*q`qC zaOz>5L$B0%lkRBs=XuO)>FB&#ILNp+78^}#NA+#V1hxwXya+Mr@$jh8k*YX8FP*W< zXV8e7Kd3M#{M8g$%+Fo_7iBy@E=*^PrEDnQT_?YJzn$#?E{tSKA`9=erb32Eu<=u< zw;vM@DsI4CEcA0{GQ4Qm2VUb4v+9@{rcAnrH>U98otXSj;=AlyGh;lc@6HDs^D#a3 z(&vHEY-hZsm#4dT^Q%{hn<#dJEbzKwsdspA!uNv{ZUaO`B5Wz2(0`4n&85=ucWA!~ zBVrLy?~<-(rRG=k@ZK4>i-OE|#Yjp>ohd-Oi|Vi-(t72LPDih3!!o9_oji5Xmxc^q zyq`~Hd$i0x2W4lNG|y%;E#l?D(11jsB?09JM_4-VWikTel-!H)A(JEvW@Wtp?ZUH( zDS)-+>sVM1`mn1w5e$j875A7;jI$9sh|dKOlDrtQRDa2A999!ZQRyvrF`Fjz{V;nE z;a8~%IAY#X5K-u(Yt?>+VJuszhN^054>?vKR^zJ{rj59a_f{29F*xbcy4fqKB#dpq z@GK!K>`t-tPV;2j9wC{@{WS}iT`1EBzC|WNT*pJ$qC2lSFzsqMRp#UQ*4>4s0j{^B z0%M>Km$6N^N{lzq>XIxmJwR&-gfdy-Plzd_QZChI@lK@;2oVn{Fzmy!RpG-+&X!lu zy4H^ZV(zfGI1ZfSz^A@YSelfi8T+#7{_+H^zbx#_s)|LUTOF3&G&+rAW*WZ2f_&~S zX_y}vr)JXYCg<-8;np@lbX)hO=>6!??BZLxGXtQTqM~N80_s%-||Y) z?xb&ZZM|mYFh#98G~Hy3PYqs3mrz;1nD8 zbY-cbHo;OIYbX~a;q0rXeX2m(PnD_vp%v-a;q5=-u_x zs-@V1{VpLAipCs-oG?O0f>~N(K*hy6mz=cw!i0j)6eSRF-~R^pN!$dXWcRFSU;K!U z(;z(^%FR4PGQIYJn)&g)?*~Iw23k+v#`NxfHzC`iA8!^-%b|)-3@qL{M&?>ulT5T2 z`jp?5R;f>tLQ1P4_xW8g742zHJX={X6kIgy_)DxP8@@s%Klt|Emw=w~K%|G`I1z81(+*6l$1K=S!B#8+;I0l3I8Hx$b9td5yPoUfYQlh5a}1p(^UD zCKP{m?#7;yRMBAk!4a-W_-nw}p;zCcVg&pd*TkPy-D@)561+nYg56QXIw=7&q5Jbz z=>-ihaBuMA0(6bjQhHXG3DKa)5$1=%0>1m01z4%VGV9?%-2S!xv^0mz4dDkoV;b%f z>9ddw>-SNKw4vaS`~;2}aDBPwFOmhz&d>>5AHA>F6rcNz2CW_uYrFMIkuUN{%&??^2Jz!k z+IEj7p2MvD))vU=+4leU#BE2jXFz<##8=8j^WAhIt1riZv}fHcN&-`|SAUb14X9SV zT9y1Ixm(4I5HBXwzrM1ssDc>*fzEdha)EPnd**L|lm_r6`ysB;fJcS0KDlCHA97xy zmni|Y?}^(V+ju1;BnV-~YVUy(U7I~1weo!Z{M{BGY=zm6O4&!F}R zKY?7FAfkrYUD+w+HKE4Ra&MAHJwpy_lA=08eU4`TQ3YGR{xK-)w^QF@1+r9d*FxF^ zf9uoHcpOf^tbVgvpJke`8_gO{eoG(oy_m%0?`Zbp|K0r~@B%7f)1zgZ$x*-B+S)n; zlu1^{T!%lj2e0yjz;s4^EP!09d4JOpJ3q7x|4f~WSvID?8z=~hi5Z~2 z)Yg|&nJNgX<5F5lS9Wr4+LVQ>sjpwvUL4E}>iZ{bn{M)D@JaXI?CU}oV}@{?^&oFs zV1?lOEjqP`3)tyE^lvwi1Ssi}>a|m`1!KH3K}j~)HHRCKLP|9N<*d$3FY+fZx3FO6 z{f>Pu+*;~(q6_9;FKA=W5Q$T7;K$aFRV289(Vb?@UeaTih@t*5c11=_h zNY(m*?E%_pl*i0Ymw143;SBcnq{*eRnd*%jvbROwIs8q?@QZoGBt}GKlpf7;`XGr|*N8b1W(DF;I4)QQ4Tq-oAR&)Wzo9 z$kybioj)RKbo?Z5jG&Rud0t$lBId!Mm~*q3PQWMf0xzWI+^S|u5GphnkX?-FuEFf~ zy+|1^rZ^Tv6a+vj$ID@)a(t!Rp=pXsvlxV9H<}c6?BdhXxL2)O)ilBxNZuK+9=`gU zO4BTVlXD&Dl0nLib4O&>Y2$KkLCRgfQH7^`5Y1C&{S#$HGQa9J{!Zz`u(vRwk#62M z5Z;+nPwKI;aSj$Hkl|ZM()vYy2F{;kxjze(x9xie`Yeh1&zNURIp-Dh_!*N7;u(gW z*ms(^v5L`kLflS6A2dB)j(`dD{lVO$Z|QZ$d;*WuNPtPRqpd;ILr@LlE?KY`&Fn5_ z#z~wZgQt5i2D)ybR2Vn-aKx3K+i{O`3>xs+ne0N5j%Mgd`;gM=zLe46A3cq#s1-Yl z>&M%~+$&RitU%wkv#}&pq4CqF9prjE-WrM^JKBDF?4AK8@~ivk5yGGm?irTjK|Ts1)bax5rydrH?y6+kfy@L(>U$3ZPYJeyu!Xt z8tJ52T*@qBqBOQ-^4nR!X*8aA>f2cmC^ScB^4o=lhdG;6Q6vIPidEs&48l1b9bE%f z7-%qxr>?8-1|ic4)FSXRI=V+6H=RLgr`VpXZ^Z|hg+QcQfHdbf49lEP>Aw(NPM2?; ze9SZ<>w=$nted>(Bo^R1t=Gv%PV)v6PyZ*V-9O{|XHh9Mw)6%w%S-W6>gU-6wU$%0 z4$lfWKuQ@NM=VgZVp3=Dr(#|J-N#bjVx3T!_Mg`mph&TR$maV`M-#(~gW|shPvNd< zT7op|IrXj1_e#H0znhdF2Fvynnac0mOAFpV)0N(4F?L^q^M#8E|KQm!WCH>Xl&-EW zRD5sx%4GA#LvL*RpOjswazN@V*IuYZ_uuhgENunBm~!iHX%NC^ z=+M08m>PVVm^ocqH zwnBdE^P$G2Kq~WrK6T1QCKjtKP$WIx$n4G+?D!fy2lnXz!`YlT>$nFxTWX|sopVpS zvtX?a&IzE=9xk5kCs1_=3aP(fpIsKaeE+qM31RnHjtdCJ7zDAZ-41|6 z^Nm?Bi~06i3Y50+r}w>*oHzUECDr#b$qa0jS&MT9)9y0(WC(eq@s5dY%;W}UH!5cL zO#wAc?L!tjK68>>4}eUzeLud%U0?EDYOcq~O&QI}Z1$3=Cy!luELy;pHk#GvkT&Ab z-MpYA&)CUHDy4}fW_e|_whOms|)*tofe>& z{>zqL^SDLZhhI%hCvR6eYbhJ|B|xhe=T11 zh*$s09XrV0oBk<>*RHtq&hHY@9sMaRy;hxfVij5V{Dj`fcv>W!y>Z+` z?_`SS-lqAzuj%OBL#4x?+&Q`T72TZbhcnHsx|@c|>@4HK*E%^eesf z+Rqe`*VPMOlt{0+e;s{%@k*qkwvYkw4`Z48$0fL|@z~{rbOJ7QVJy zwk?Iz?Z?S2eXhX!ySLrchy1pI^ObT~;4AnJVH$2dST^qp^Z$Rjzm_b6S6v_;e0{_D z)Ac#O_CSR{QXdmzlF0d0R(Y;NOGjgQnoqW`En#aYb-Q&eNZ~l_stSh?mz5xo(;{h; zT-&n5Uwe^GV#nt*XU>@1;eUQE$);Bj%!WEB(DtW6@N&6yjgUn*6rTwqW1SYobIXNn z@=Cf`f?xWc*)NvFAHV9?|BZ$!NP-P7Oc&G}@<-YBb^oys`R#GFK5O;IUBu_>}cJ`snSAauKRgA>rY>q**f@dQXj2}TbB=-_O)13+}`Ng!OmciE~cgF`hj`!#27Yi@Zr+chH8XMLF zltCY6>Cs1LcN)5&KV6(M_Xg7b202eBEdNmEb`y$6)Z$e?pjj|T-YqZg3U zPD$fL9L4vb%Q9rMH?Put*;V3)?T0^a*Ui3l4-6tK<81d3Wjzb+-(jq-*OLdfcma2 z#^=euzBZ>T2VVZc4k*LHLGbF0qv>UHho2xa-Eq*|O+n0J1)N7UQk}=9&dQH~OlNPT zKRtIK3}WP`OVTxR?raJ@;>o%+2nHsx3!IJ39WQLSwSc3V<5=_g>6+&8!w9l+!nOeJMjYi(pZ}K5ylq{du4hGx+&zyzx$x z>ezeb5o&pwX1i~Ch1r>CyU%BKfX%Z0c%FV8W8>PQ_dc3|VrHU##)4XJPDlf;->B{0 zTsGACX!=c#e~Fl3(6O~oZxjYo!jU6oFHVPQC&%wQ1i9MK37Bi%dIi3DcO-f)-BI#? zsfUPPp3(#8T4K6!rgieb$nc@{i_i(PAiheiLGO)8cw)RSu+vBpg948?_qHDMFF0knIKC159+Eoxq%cUL(pAq- z>+-8x8?9W1KJ}Mxj3M=e;(Xk>?Q>J&E!(lLan8g%Ff>(fY^bArtI49?K;FaQ+cx!; zYsn3d87|eRU~sf7yzGogye1#O{U7~lb(|11LiFP*tns5od=KBBZj+=qIwJMboJoeR z+V7d9nnjT2#iqUImH=(w7TP#~`4h;3<%bigPSwz1&>zeL1L8MZksE-f7TK=Phi}i{Eyb4==KivM~ zWsU}>Qq-v8&j1kvL)9neV}v6WGbKOo8b9}W?koqGMv&+<9DeD7KD2p*QF*veqRXeQ zDs<83mpZBTa_+0Ti#peMFmYHv`ygsouf?m3+7P3qU~dAV7`@!P>21nSpwgCYk+x^f zYP-&36PPDT5PeJGA=V2EdH?TMK%U-N3P)3hQ&wbsu8c4vXCagVMDNvE*PZcAOV}bU z;eB2B629I6qJeJPYG*@m=(jkuoX2(b#Q&W_mQkw?TFB7y5QmYTo9R^;V!ThZ_9{QQ z5g7rcfUX@9Juja|5Z z|7k9&m%l!eFQV~6m~v1^<;_$@bL$iBcS;`1Im<|0H7&A36`Iq;r zIg=fi+$pHFGW^FuUyfk;&{8-74+ZcQK_3n?&P_60;+I{i1C<9VG!>e3Lu}{scIgcQ zXUwhZtYrL*q6u>28`7(8v8&_(ud}c}jy!6P&kHCoi8VN_5odVjwlK@Vs?mO%{3NT+ zrW_Et_<}>B%71+CeBRn6T_aoyyNqe$rJiTvPGeULDx;%N&SM?5&5^8g{wR5x_yEWq<1X)+hZ4@=$AaiW_PG-z#6N5&wbyNyq!M?uIQS;iOIs5v$_M%0nw8C$4 z{J{v042Qyks;BFC)0Q~q*@|FCHp*-ixwKh3+4`QgP2HJ#rxESJV`9eXx-0PFu*_=Pt?dU#Qvt~^7vMQITef312a1}=8(Rf2W3Q5q z7|_E*V&^O&_7SeV?=pf(s8+7?g$hHTnF#1GsC3Xwb%RqmIiL%^F((x_bwq@JB;~{g`e&?zQfBE^NyWB;Ay#mKJ zSv^P@8Yt+0bqFzZjA!v9Q+`kZELpN|G7z`QVvdxQ7+-hi0_P+apf9tv{6SS&q58=!Bo*%xctGtEjVa_w9!{ zatk1->x;iUu|hV~y^?ctmA;o{6;Rf>gV{(CD02lYxIECs*ZV%j&T8@=IQ_{mZ zjrbsi;pLZ1!3HzuyCJ(uLW?|DQ8Mbe)<*SG)n(@1AmN%gBmUiIyLc4*1gS16J7+8P zn3w2Up;e7U4ByTG=62}Xsh=obZOlk$_49>cUkhGs2duOE17 zhLauufJT02MZ@5v^<@thTjhCy5EZS zA1q!4JpjT9fI`|IAh_u@47i}Oq_=r5-EqIY;||fL(U;9 zurKAevl$E3hsdoPgn>LFs9m-y*1)?8Zj$N7)RqQOxUynvz1aF%r}diLM{2lDdPR?K zfQ*T4lyJRJ99Cv*Mv_~f%vU3E%w+I9a4jp9JwI+;Ia#P+v@8~ z>_Y0MDHgGIw;K5603~O!Z8w;Q&6DbY&<4#b({*Kgp%HRFdSY8ephh2_+^7l&6HRA1 z$?e#8kW;;kl_#CO?Ygw98}H+D$_5$8Q$=BAAyG0nPYrMD=8P-iyPmFbt--j2#9WZa zH+;WR$3aPYv?24|W&t+W3&%+?`wl&HwQ+lpjr9kuo`lv_drR;(Y(IwC*2 z@#T>zSq4|vb33y3_Qri4UX{F~4r@L49Lk6~QF2E+oNn6)X-|SQ3l(et%hjLu~cFy{eapW*eFwNlu= z8ylUFO}4QY1jLpsz9%RK&2065bMtM8ocHZzF$UGfe2F$qu+(&o9wO_L`&ZtoS#B@y zj9|H_0qHB+TRgCmA{Az+W*ir%e`(}5B%?&I)6vRE1FociKmtzCO z%uIO)`*XO}R3b#E9h{OS@P;RzM*|R+YIhs`4lTK&Zc-6vq_*zmd1O~qLcRXOf^sLz z1GtU+|F5?z4~Kg1+Z2)Ak#Ow05ZOtVNC@F5OSaIM$d<9MB}<`*WE&iWLdKF^CX}Ta zV=0V%-^nsDhB4l+&huQ?b6xLwI`4Ac_aEjTuKE4u`}=(E`~KXYdoW(w56s`*N*|JU z9kVRFv|Z;xqwVwdcex9US`*mt#neY|2sgQ=vxBfu2F7g=$Loye4V zL8~wEI&X?@ZdbS@8w>lmNbLZ9qg_R99}rFO-54}lXi`SB<1cr8D}Dkgfx_=!B}PRs z;=vr=N$Qa#B_TrYcDU(+9CO1fX|qhd7*=Rb$o3P%%v~keVLiPMb$5f0iq>E$K}<&Z zWbVdlJ{S0e4OG5Pmyo~E=Kfn>^1XQ}FM$E_@tqiMzsJ0(zes)#01lLUi|5Tsl~MA_ z+Dwj=a#on4uR>9p`VyGRHT`}rW8scPf?5I0%d`+zqo5mqda;s_+EL54x1)ENUm z2PD&wOD!X>*juc`fO9%U#39%b9h)q-ar+F=`mikcBN9TS#WD$AkaB>Kcf*>TmA2#J#W~>7g?E0a zO5F>1vgFow0(Jkf$7>j(kpw}xJ_CK8v!1liiJr-@s&ejHubmVJ62a4) zo*3?kSLw#W4nxSdJo6DDh4x62Gh{G7L0y1T(d}{0$xUhtrowLbHI@AZ*xs~mXB(q& zinRxaDDYDB%*@vF*&KYy@knt>ILrtnZlk2Tte{!vN-Pj%=5rm{2}b%6a`Mx8aZ<9$ zfa_bl0y5hD6MOvz(w~ya!6{1f0pL#^tgFK~#~(3V>}(<>gcI{?(@famDU3g@p^m4Z z;Ut{9vqX+n73o~EsImCS#GMiF&F`FkHN+V<tw0kud8i?Dxs<3Afaufo(Tzxua z&HLgU8YJm09HAU`(q7EW1-s*E>@^7bsLE<7?9Ru>&qX@Osij2?*`;eI_My1Fc)94# zsTOT?b6E9qUn0MDH{pWc8arWLI^5wIf3NoIQ`gmYU3VJ*AA)XFSy^{wzgamG|zJfq*Y2}qWz zzhcSCSt*+|e+{{$XIjWQz2!eO7KqL1$~>x~;)Oes1}QANabL~k=12#kD7eaK?M5xP z7AvjlZS0_lGdiuJzwW-1$`NrIpk%(=SFU?)eG;5?B9t$tIjg5c+8)&eeXU-7?mg-r zE;H&r4(qY)Zw{q`J#!Qu>dkFKJBmHXJV&IGj%dE77%={X-4T&ISoiddSHO-Ug7JdgJ>dA44;vrpYV2R$sUlSTxOsG4bLr4* zm+L-b3NWBx7RuD+i)dBb{7`WxLu+LyYBvveYmx5iCbJz0sf-%L3xlkWLRyAF=a4=i z4uyBI9PL4gI)Mx6+Vh}I$f9)DH|aP@_~kDhZY$xFp~-hzA}F^3E_8jzsT3v-qP`xC z`FvrIhK^qT8Mw($YZ)di+Vg=u06xNk5>@B@j-3~mA6-`nRyhPlBN>Tao?!HQNh$hy zCip}7%j}xHr}~%HnS{>0XrXySE5*)_dinmmb_|1J2ZQRGSN-lt&gXB9wHMToIE?BX zzz4-)pI#2Z0~f5ESqG_jOJC+3u7%GRq}s{%OjjfK2gHU$@#FIw;W;8hHZb3vl8+Rs zt3!yA`=yE%tCNFMZr_(Q^g5m$puo9-oK<`6$f^NY#l3(ud8&Inkj` zdy=gdW|M&jr>5&9;}|b*T=~gpcX|jIazV9Hg$-(? zt}TBlLezg{FwF4D0dZq213f~kIES9TY{m%5wV|je73vT`M+UjVcZXFLCP8a3I*#2# zU8dre1lu5=WZg$*`y284+c6sPGP&K8q+KPIML6tfjdg9=f_?lG6#>Ic*?DG{@gQCp zmjmYsIIZ+04TfQw3blzVph}hK&bE&`lDK7NBHV*)K(BFGfb9$nbppyLQk11A*8G)? zpW}r514;`Zdb4m`nBv?9i8Gxi84VRn`Zuc>gVhyd%OQn?pO5M4MgY~F+dieA<;)ft3lSllt3rCInHwj(b2qs@s zs*v|@;zGA}D863(-LyV@W*QvbbxmA5BKc=($ zbxidz`JDXj%-IgY2Ofg5G?OfHe1xw#nyt)j;Her z0E>Xo+9N8)m%vHT6?vKkZL1ik4=&rjADW|LS%xG9RoM?O^;!R7xCVXM*umjriUW62 ziDGH2-mFRMtvxcVzueR*u>6Df=f`7PG^T96ABHj;ux$3mn(lvtrAdFp5k!ewAhHeN z%KF)X&VF36SzcPw^~7xnBfSR(@{8!e>)=@G1nw^ni==H$(_%C|*iZgODOde&W-XdC zOwhK$=Pff_?eM!K_#0F)>vDy5VhFARjKCLPEi%K;-}-**DHB>fdedz(6e%#6{;<%8e#&I1SmKlfaPew(u%%-dG+eNwo^0WV#^e^9fQJzW=4$d*G%;=7388 z&*GOnow)ciCchrHYn9*u=)OXx2wN}=BoW^FQ#}Ek3Z*2|2ilVHCtC&bX0d~8pS%!a z->bIajb#l@WOwno>0gSTK zV|eg4ujNT3A_TOz?Ots&$Y5!@NMuWEvI(H7d^C48Gj6vtu*b5(ch-X|K@dz1x`{d9 zHUbC3s2CI)9|f1{$EkIM;LiDZoqcE^Z;D##L&_x}>Sn}~L&t(Q>>qhBF|8Q=fLwPF ze6*o|RDCmQ8|OHb`w>Xq)FlxYt0{4$k4a`}u?j<*5fJ(fW#hYnGlexqIgDP{?FWe-Zx4 z@o4IElJGaSLL-uaf=+t^GTDD%jNzvoq?#G7aBt9%C)6inc!^7?y>Vx76tdJSH`lE+ z8Lz+(Dm0n-B3UGz`Wmte6xfyl2%plKkKN>_=#&eBobU(t#<=+Oa~Ol45f+j;G|7 z5N-3d*b@3wZyxW?hZR!v{Qu~ne>~#5Twl!2TyP*7XUU3 z7jH*G?(+D6o$ec6%fXXD$K<%bMo~)PEkYawFyeqVMhn48MA=E0NhW_EZw-~UB6O%{$PUt zF>%=#p}=oNk$r^mmr5x?G+%2^I5G$s`*H9np}zz7&_ZQ5@D*g<@WPfmh#2u`grT_k zG3p0yEGE+nWY{6PgPimB&10`3tB<`-xj%i_tXqc3skp?B*`COYfQ9s;`HXvIu{pFl z!Cs2aXv<_qn}BJ;7M%8>0q=8etUr%YE5qgODB0clbWmMoQg1r)o(IpINSt{A|CRmC zHpZ+*$W{M7;#NX9`y~CDhn(h@FY}J)R@DI5oIOQXj|NthxyH%&H~c07p)f9WkaHX$@g~<7(!EWA}nWLQddUg&XonAG6oeB(rQovS5zfH3h&0iAT5gN<~fVPW#*082wxJ-N0 zA>KGPAtp~vnNxAit?J1^Q{a(eoU2shnr@|=Rks(FwX$vaA-xD(WiC;>dj%n1@_3C& z$VkjAlh0)I9^(DXg}29-Iygn=U)1!yeE5b4hWdxML8sQ4uutqO274y zy#TS1M{&^_lmQz{CXKCg-PWs(xGwLeHHg#c7b+9j}gdFba(wVTQN)WEFI=^%ugwZ`zh$BR)*ZzDDDex#$smH zSQt7;y`a4enRICcfMW>qa3r(AEYGf@jdD5?1p8Oq^6)#R48UGdz_qeDK=wJ8;ctDJc*DTM z%N%+6@r(ul7zXzQg^GU3pQ& z#}RUo&dh!RH2{AYfHBQ%M*+x{)JVZkh9mtZD*phs1C-DK{`hrWbAIPH`ZNXqDiq(v z6&ejky%ktE#6A31srWq*VKcJIOt#-DYkLi@ezcQaCdJIQPqhuz!=THH`cC+IN*fV7*KBdy zVzQ<=k1&{4utN2571Oa*>vTVc{5%Oe&9Yn3L%Q2)UaUV$48cw06=MXxYNRE2fZohX+JLIQw#tdRo6P z8>h`G0$uj$PwPd-6kSOmFi%(g zf;n8LWjI5D_j4r#1J{>*=!GK5_X|(9x?u6Rrf^y`qu)_t`%D zw(cAKzI)Rjn!$J5TAvf{z+lRFjeLR;hQfyxWE;OZcSA$%TA|7Dl~bOA?m}LYT^Sx~-PVIA2^+6St(EhZIUwdg3GFL-VHVV<7wp+&^;oj==k_n z`{${vm<6Q<;A+7&=nMtbFm(YJI#sebp3z)ubz9rte+DU-e}U`B-|hVn3Eg9q=v`aX zzRYwS?A;Yj&xsDNt}aS9((^jYOetb|cZSyZ`oowYGBS!{{VSJmgxqdgnv{2lH>v=o ztEQGD6g!^0o&~DbkD>*f8R>&}OMhuAmix`-X9Ei(udA`N-|T4CRJD`bUf%;oEeUV0 ztFBc4c55e!2(+|`g{xi#x8`-&;V$`7lu%1Zf#Lzn8d;E-36i7kR zI;A;o4)$hVop$}_;=)Q2@7vLL&-Z~(Vw<(ZHH7z{&Jwc!o0~HZl(o5~{6-Y5bQ9ms z$`C%9j_l73FVnK!Qnn49CA4g)Q3Ddof62kox~Q8frJJS-X(sKjZpr)>{EFUH&*im5 z*c$SPR2g=7DiC=8v)W~qoxBPeCINT7^{3o5BzeBi=EXW6sKoX3n%4QSzqDT z=h(!@6Mztr9@!HnD**;bSH)8Tr-E6Y8Xblm{zkAHyw!_; z`c}1wA8hfrYCVL5zttBJ+8P0=*n&kSZcpKQG?b?CpHn0t%l)+w&{Zi#?L5Kv>5Oq{ z6yJWPvcR4$3vqRf+GT_E_h0(!EsQ_hi+}F|rJ%7_boXTc*nTzK=TqGcmjBO9GZOEO X0#?)dND}*Tl#9O3^($psw~*({X(&v-^}lS&be#db=SFT9j z_Hypsv112B`RbKFcI;rb+_7W#t$#3q|KYwqbb7~*BRiC@T)O3GFxIsvQqLqrcD=D{ z*fJ`CX!*G7L>4*_|BtdaSx-K{d+qz`+?k2E*Ry&(0Tr8~j7v0L_LsIa~s8XfWE>(6T|alE05{2?yFt$;Q>|FN$> zZ$vEH8lHW?LYt4nET4Wo{>can7eUN8(@oW{AYjb;yebKifvb0!z(sya{;C1@JsYQIpVU}R)8gG3;D zl1#ld{}^q@M~NeE&y21u_K=1-zD@lYr$rk+@n}^C-nF%`n?F50eWq*DO#&l4|2)9u zO0?UudH!@cSN0%*-|Q5x^H8i!tG+{p2dnIb&vnz?p&|%jxwT>S?7<^*E*I54>E4No zeNZC(X)Tmkq|lP4a~!eSdpM~4bTGzGpr{s^k*{b~Lc8bGv(gNymDH!~C`mc6R()c^0_o&=U1k5!7{rU9+Z5(@iDRn_qMa z9;E0we(}^fcwRpn?KT@R6M}Ow(FhSny}x|3vE>g9?;n@$U8}ZeyB2a{Dl&6*L`%!0 zc4xRJnHN*EN~@KqEOJu6SBq9rlbO$<-dE}2S{)_1U1bw7I00Ux>O$uMCGO*elOPmUre%!=!)7Y*t=bZIV7 z&~oNzbePlZ0RJR(j`LKwRFnk5r#1d$@#Tx@`Jew}`IBfb>>3j*+E{MmMtbbuc&6zE zM8*`D!*9FhrmHo&t#@&1EPK`%SG*AN{J2tOXMR8h_+#a)Ku`Bzy*PBk+}!+mbE;OA z-1;M_T>&eu1Yf((%~exV(=_n zoTf@wV44dm=+nNdkC-P^Mq(f6UB#IduP-CB;U|rt(eCT{RErM!S`XhzGInu}KrGha zb)r6k`tKU3##p?yP8fYm;hW4K>{c$h`ymU;NpSSFD;Q4VqP{<<{(R3t-JrpGAvLlP zs;#%B(u>#mN`rJ6#$|O{iOH9FIHdG~EVt=8GPvd?>5U_Ds^lH4xZ_vMNQJ;k>n=Z4 zG4@79edm=lB^X}hv*$%_h4Ju zf!I}qW9D`Iy<{#?M6)w(_m5uB2UEsY-6(2QezM)RR_3_WWD#=aLUnzcbbm0BU)9f( z1@VkPuzz{cuHzwAr2LBfgZUB7b4KNO(RU{n{SL`g&zHIMx3gEwl10Ei*!HzgTpA z=Z8p%&inf3tlXl`I=nXYfeWFDU1GF8KAd{_`}7x?(GPR=@t3u_KH7bI>zY@jM}@0f>!mKI4sm4yH-nO!lUxNLzSWzJm6DdRZr>NsrqM?r>KWCa*PTV8M<>ARH&Oa09AKn`TCcQF{ z-zN}zDnSdA1Zl`Q;7!>tf+!IxTt29(+L^UfDd>LT)yr`ID1)PzYH=-rfO?l|34zAG zJ;lUZZfZ?&6+X17)pwpOfwYE!GgeWNyh}=+1VLcB%o&x9 zr0>>HTY3Y|_Nuv2TU-NnJWVG%Ff~&iANo*+?ayLIu>0z6%v!FT82Md%Eba=g;k> z%og7ms}~u$C1$=29mhZy&7AdDqR|exJwrn^xvA_48sP8c=9oU@H8% zJeW~VZFIGSQT?+kvuu#7zT)N$)Pw`xqEiM_F3#M;nrSI6M&dbY!@9lpWXD<>MKJj? zF55q%WO6n9ORiZ>T&eHL$xknFYMfQW%eQ~hYMCJRAhXC$w1M1^T*C;1mz)*^%?`Zh z+(Jp--Cv0()VN%RQ4j0W-lLuj*2cu>?F=Vbz}yS<^{T@SWLPe+b#@bToY)Hb@1H@P z3x!E)3~MIlb%`1*zGD$NF`HN$pYK%8VQNf1;7q!MYKs%~UwjCc41=l3<5F`BBVeyY z_xtnt4drlnGc8AS&m~bUdXH%vb=DNj?d@!UUkIy2r0AtQ(7#+~?AA4E5GF&o^J66a zf{UP_THSE(&TB*MWxZt%LV7w%*(i%h6etY*t@@^zc0ISE(Lc^AU37Eyxhu9~C-JBn z?xZSLq9f|Gn5>c?=ON3sxII2br(Dq~4U{9v)#TM?Qr;PHS6PCgkI|#K+jUN{tvY1Y z9*Guqq&u)!70;D;BgEpS=UvA#l>*@$Wp`vrY_@%SyPt&_=xR^DXu+a7P6?rY4((ot zHJuf5-S4M%6~|rOle7eaxjD9&7+vRxz6a7`=j+X?CB)^4vmacB^Vdv%jw0<}G6=JEk=A8+w3JX}I5t*U+jf&^P zGnQN^bGK{FF2*bG&+8>zU1!(O&1Rd4*{zo%HIWyOulKR;nZioknv*1iz$~juG0dIL z-(@^cuqSPSEzfI!FJ11qqZ*MEi!~g!H7iIWj;eCu^H3A<0kUGRruW2BxQ(P;WY1(Y zDD;V#7UWw0j?jd|J6lNMbCn?4{&APi}%2VWoWOz=V^9%}& zf!iPr^*oxNzEaZ!*zNPcXz+Are%l6#R^c4}unZ6G2@@Zl_R zYz@SjM?jvU9RI6dy$yttlfq*5A_?+2Zm_=TeRG-IQYmZT;FJ^5Y$3_^F(>XBx+Dqd zpL;DL9=ruakSs9J2{FtO2JMkB>w0!_M@Zp^`^(~&Jb3)5IXXVL4@A=1c-sDng3$~r z`!zXa-mj{vsvz84J7r#p)pP0(nMo9JJ^iw$Bgf=_tZ(xmR^q5D^*J+0MJA}Wx9i`2 zLdbAfoEnHxiSQa8cWbn+zt0Fzm|e<+!t+7uwr7{s(|t>y;Cebff;ahG?$Gtbx`tC(7z-LJH7ehPO6(a$qqj`J|ge!mZe69wXh z-r`L+7(VGrQk~xFC<1-0{3=($L#z;Ih+`zWEeUc{?68U)@{m>*2>(X2jozao2S4RY zARR*o%pRykOY_rQ^FiqTDN(B^Nwuxy3=JgCXga4KOFwMIhZPwt#j2--RKMi z{=59j!(IEYw=mzIKWjyaLyq$sN@TyHHB zRvP2(R?=y!-cQuRmWo+Ewb)&)`}^_R8p=>E!j*47^CmYS@Hkyy^G6Q+_=P6~&Y1-u z250L30fO*>z~3lEfB$NaaV|5kiH$ET+kX9)yfhz%S(<+YLDSz}?EQ}qz_2V7D+IGl z;RVpox0}9&_VRL_3iP`< zg4se;AyH9L%4PO_@s80;sZXn+Iih!MS$uaY^qJY3Z6EjT*v`vbyCi>WHp4NeCv|)s z$|~0HKm^>(NZY2);>}C&%f=0W3o2rYzh0`&0t^f;2ZksMl+Wk%Gx2}HofFRZ_vVlr z6WO>v7$wT%_QA~|Pie(xemW4eG3u-4BVKXgdr&-{9A34`vUBia;b#jp3yOH!_mg$Y*~W zn7Y99yXq;WU0MmC zrXHJdsS4DZy76ouTjX%3*_wf8>{0#Kr{O+`Y7f?dM}F6q@xbI? zD^`_W^a^EsUW(XtLn9dT$*}t9t@!>sQSw!P{TY9XIlWwP`??#SFy>cDr&|ee_I>?}&m%yF3GW}<&v=7o3aY!- zF^KTRMFEPGK&(Ec2j3NJ3@m*1LBE<+TPHt1)e4-eD;G>gLvnlb0oU^igenrIwaFb9 zPgZ1QfS2C57*4vFoo;luUC=TY?2~`mb(d00%SFPFxYmuAz=GPR0v?K3sN`Lnt$hWK zH*#{#-&*F!x7QzI^ZD_6#c__$Z*A7h{N3dbXsZD)(DM*R*TVX96${tgQ{KwP?Q1Ah$dL*Zb@Snr%qOLCS89SxVlHQlm6<3I5Kqg3#)ruoFSKU zPp;L#p|vlnA)`Z)Li*))Y4fd4@CH%qYc_fMK8Wtn{g|u0y33`yHtOYl(Q{?Fb7iv> z=EVfQ3FVEu33YRHTlxIm7cq6ZST{DFbN4>6w>u3+F%ZymZmSnKMDJb+J#e6&5KAO# z-OkYC_hBnORx2ZcC@3tf;&p7nL+`H18^4wsdgVdIa#^w0;i1lGNYOj{2+d(q61XX6 z_^Ij_k<(V0sBmPwxUY{&X84B}_w@^nf=~rHAGKH=4E4Fv$8d3^ad(lE zr(7LQsVqNML&fqO6HzbAh~}u*5Y@IP0#SH%Emc8wv@07_vIEp zgRwf*{zT9`h=`}j++c^%-|ZIJm)Aj<8;uHsp>okVl^~k>uMRMClIHHFtk#|mkXc}N z8p|XnBqy6{c=Jt3C?k5XW7bbL1Jkem>`C>%8g?os(%LeOOaN&YLa{1D{Z7i7;36cR zRDXG_kF5TYmsNZT;1>#6Iz+c9A;)#vn6;!{YxOl!Y(gopzV++JuE72!V;O{~YPg3= z0W{yGiISCxF;U2^j_oNUFFCj1!n(T++VQax7+FP)#(!|scM4TkSC5!dXKsHCDGM(KT~`|^k!SAq%1VR)?M+{aNb zk0F!!j(17%9FyT`H0gUCOFQ6`+km$U5p$>xs;}*V8bvSr_-gZ%H1^*yl>3qqKH7>4 zvctwK9xkmmci^wCQ#0Po1^cMc)pgFj>*AiC6Ix!9;Y$920|U>M zCr`PLb~!)9*);@>>WIjUwjRpJ{qXU5ym8Zzs0FJP{(fXv7cy`?wi$_7(WP}w-q*Tg zU>E_tW}u{$oa6AoI6$L~9odo=3MrOV?KpN^QLO4krV)#}wB^Sy3tqa=H2rKv0~hPq zi-%h6&%4=9{96v2mfOS$DN8r>iAg3B2r7+F4zy#q)nH-)A&E+^1})<@OZT1O6N++c zTIPlG$cCouiG&&$nFS95vW;DRc>{+WnpGe>F#iSOzYWmQ4Na5{_V5`K8rnbyJ z>=@bJ3ToDb($8#}@Bdl?&FUw99xh~K7WL2B$syiz~~ z@2-n|Z9flt?bPB5&ozRV@0MD#Y}>!zYI;D?_(P=U$4CUz zzzCHx68WNk{6c^f+QB;MrBTK2kv;isZ`j&CCNTG0$s8 z>;bDgv7*5_xnsBmgTj-M*WJE7ZVVeQy|1pjULa3zYpN-@s^pT1C zNAGF0-W;s#c!{eju}|Qd4d6=b!y&49G4P(ov89cry1m)%MY=Yv}`*Ap$wnDcaOxZrw!;}oz1Ol)?zJ}_svpuKI09n_iu!)-`<7#^7moux0gh1{R26& zFANLqHicsb@oOB&+Q;Ml{Tl8<5LIhpW4Z*F#9U=CqF5_jtLR1u(chKm!jX{$b&p| z%CM+chLo-FpXO>_2DBsMS#DhgW7beVZt-hCV*;-_?UkEjNNEvO4z+`xalsuFK!>>; z)OfU|0}YTJix<~>_^j?fmeadHz=fo`J*Tj|Yr$AVOEBh%t#WItb1I0n=K zk~PDMOQ|XMwlNU^Sb#KdYlyEY79)B3mMv~VBi|)&TWCg#{Z_K}HF?_@)#J7Y_l<)V zBM+`e9s`+(nj4=*8w>{H=bOLn&zLop{I=OL?+q$X7q%j#Q%ooAYb9!FLRr_YcT5xt zVaA~D%_j9FvYF|s;aprnShJzXw8tRLh>B0fsb?SvLKyCgiY|JCW($w67qn(PpP0z4 zn3PBEF>~uXewB!AfZKWbKZj86l-UOg={NMe6-2dV=w;sZQAj)FvXiX-;zsH)w%AEs z=B3N{3K!wSVwGtb0kO_iS(@e2$oUxD3X~oKbBH2u0F-4j~s^T;cUH z#}Gc3tDZ=YO?RN7Po3W1+7PgC_-NLb@6yk|&r4TzLt$3eJTvfJY`FwG%ky#wnboN> zYVk1=$cZ0W^Lmqr^Am&HlrzSrrqFJDj!uy(1&9qMXtIw)@?2`=peShK4g`1w~L z!J2_ko~1h}3=xv%_O?8WZNfv^M<5ge0VX;7X%S@+i5hkwt)#C4h0RA^%th)m`2GQ zvhG!La-6^Ct7qTNO7%gk#;sBXTh=Jtt1E%o5z0@p2sRyrg z3fC4IsRcdj2Dj67`2nTk!wn{!l$35-w(x#9o+`8A8!8ZL`3&7n(hW_6_B8SBZ^J{~ zJ;z(D&RQXJu&+&Itl@JG#iI`-mp*$KUuk?YM!Q8$O3f-PC`fRS$93A)7S90a1~+_r zq+=S@j2-92j2(4f?Tg9w*hBlwEO(?8L0=imOtuiSB>VFmSC^$F09n@L^9ZXEcQl#L zy-3gcL;nnriAd1u`WUpGO%;^*(Nt~)tt{(2XXZ4LBGA8!?}Ca|-WM!2|CnXlvSAtF z-0DCl1NixuGvXO!1$MlyuD)BC%(Lj6zmDZHq0Owp}jk?!leZRTrn9k*^?mnuo`z^_pSYM-mSFQ0EO z4SsibEVp$c3;4A$N%dy4wqDsq(sDD-nAl~nZsoa;KFv2+0c2w*)upE&i;0U@Tw>-q z!MD;z4!Zc(dKHM1LW>u>;cOzp^xd--ZR?|?5)qnRzjyykl`8orip*=9Hda(6%%DBb zVyuvN|D(s24;8mmmzi&(!b-IeuDQOP;%A&#HImgr8r9|Wg$st_sMZHmJdEK3&3M<@ zRHX`fNs4B>i1Sk zZA5aUp;8Q`+?F4hb4vaZx;Bn(pB~Je%n!5FCaK+)p1-vaskpL_QW`A>=n>qfXc;Nm z8eq)?fY$J1Qo{1t#g(2#Sy~L=IzDOk&z4_u(*_j~q^pYlmZ9pAKtik6{h=rC0&PB0 zJtk;%xf<=J(Sn1E#Lz6Qapx0x5UN~+y!lB?x9vTfLuWG1$gIsdR4q;SWqX|VtaVx& zQj&|5S-9R z{04g<|E3R%gb7`9LztHlva>a;il&*`0mYU*IF+A^b}X~b za-8m^P7_P!s}nLKbV+WmHd4l7_Tr)tc;tl`*}=$!ODl~rYeKZJLtsPYL|T^@2CWvG zM(Z@YPyf=0j}kR1@a2~LW1t2?CtCr**wvQll;i=ETJr`h3Qj(u)6z`O-ShH-Qz13t z^|4U&dhQ0{^_2kILi?FO1Ut_V41AJ=hxTgSq zNU{Gh+T5nQwmE{WXj4~Je4By$j&Wn0z7=L}k$78-+ZMm}Er8vo19Ji2^kV?KO?NyD zzfndo>iun4chzQQB}mOVz7rtb5rJ-iaA{~I9>s9SF|Z?=s1PLZn0Y)*6AgizS^gr4 zq>I{g8}zfcO*SxK6$3wwftji0EbABa91aLceq6}9@^2G zY`HAmfW=}zOX}?PYk!)34`SAlR13{__kSQWbN|bQSV<6!)QRpNSk3QSkm?^c9jM?i zjeIU$GDZ?n5k4zDHq~nNX3DdqmLVHYkSG=fMpBt8E;je)DrXCbHV%s#Vn+h~_+$xl zAe=wpepMfE(~Wb6Ma{ohv=JQC*q*NkF{x6naSR#F`Vp*Ub96kEXX*N1ATaKe7M;aXD`huXL4&Ij5zQdpUk{%?_a}3ce5D zW&*c)s4Yy@+vb#(hL2Gl;0#YK^1j_TOyjj+Wv(b-FekrJj~ff^D>GVs$M&yJLt5`+ z;I)7m-e8V43E53ruWo?BH1gVhV;Q%Z!yi9SRbEP+uKb?zd{w>k_36r7#<)iK4Tk*t zaq%^qvEUfcv05J&VZ0%UZEcL`@X@*jNAGVO7^z-*cuG8s!Q7~O?prv z2$T}1){eSYZapofa5(3QaQcNwJ*U>fbrWp~%buB$n>HpDhX_sw$zmUAO1@#|7kHyw zYQ~jGdS~>zzLeiN(}dsca(g0xPhT*slc<>C3Xh1ism>DeShelBU|%G?d<|D**!nz+ z{Qf|eqiW3R+_V)@@IfwioYLd==F`jBho2%AA1vr0M}L-_eteLsF42|X_WlH?asPr{ zi6Kkkpvjq-!3e^$b+_T;Ypye;}HP0))ti78^>2lOmd!%saF{gybPx z?M(%ST1ysksppmf5u#4$al^D51;|jDw#Z+8VvHwZe+_Vw<1S-;d} z*Q$S={Ij_ctS5(|#22(=ekbAEf5KbiQZ`27uwE;i_hUft(H{P<} z5c3BsY)jkl9i$8piD&XR{T7_RlR|8O?hNe)mLHJ2;TzT%Ty&Eg-Wp<4=CEz|zY@7y z#O~&hTOy22Hk!e3Yz*mTBhFdHExKjnA%KT}ol+)fSq3OP%LV&to95H$Go%2!s>5hR zhz#E!P58AHorC>?4(V#)8D`zQQ}K^fYgcXAjlx<^()!Mi_OFLIQ>h&BRA`}J=s~w` z{za15)Un*sI!=PrWKIyb?WdbI6qLh94N(42-60X}828D2-3h4DrLqc+bsP!{0m$k# zut*J1Nb}{;djL0TFv<~QpRMy~2Y$Z4t(7N2hMBZ7KFJ~yy+Um_6>xZ13`#wWCpyVN zEHn1tRi)vAy6cPg)==?mzrS>eJHkoJXzg#`2ia1?)StX6Qh`Jw7w z1bvyFby7&SZ^64tq?YremVr~^b&6Fukc%OV$J*E=C-VBJplXAaH&t=P8Q!9SeCrIe zR0+tur`6^*B+vI;#N|t;E21P6U&7+M;a$LNLUv z&6C1_OjJXdn@alm)VVgG!@IFo{^4*{fZJRdHGh>^y{z(3RS?EI*{|6J%2@*HfYS8r z=9}9|kKZ0DR0$If(V)|sELWL%U5*qs$*#3>4~G=SS~sS;rHAzma^;+Dy9b>*Zz&i= z#|HKS^4HA$z%ees27hHQ0W9#>v}Og)#Jn%**3e!`dViCKx~gzs0T6+jG`+rAVfa}s zJUPa#K<`1lf(dI?!;yNtVPX5ahT=zhajvS{f+H~!gY1V;s{CV)C{2j{VPBX=KmHRG zLeU(RsML|jXbbl4bW-$M?R`2y-P@(DTgC>MDU8$FsDB8U$JK=nTUH(Xo!Hg+7D`r8 zq>G2p2~^vDMe_QJ0Lflv?#4`N3m!7Pz7IZk=Q2@1xD~I!XS({hCCzkKfZT#(3z^m| z;?9pOY`y2o_ZAg8Suz-XLL}<^_#ptUG}%9RaKiP(Tg`m5Qx&}btq$|O>d&iWyCev; z)FmfrVV;nd-12bVq7B=WX1G#?FgWhT-OzJKwK{q&oN7_UZr%u6Yu(dw4|*%xaK#Vp zB5&X0D&f?}rCu3bRYtn-7UgE$DI-%n^Vx&-B=JehdzfXT;N98U#Av61c51>kuGB<< zpE-+`K2h`?Ttk4k@uc~)WZWL{$2%c9de(3;t076%jvUWY81fN>QaSY>&?ikC*C6mt zgNn2CIw-lqaq7kkgDcw8yNre7U=!IXce5&E1D-YJM){_uxR z4q=t|a`rs+g1|GH=7e~9y!mc3Ok`6C?i9ct} zWg_;lG2qL4q-k|{lwMSXmh+dG5b)Sn7q24UTFpWz(|zQs_<)*_q@?Oa|8nhBhztTP&K3w_dZk9lm(Z+k0 zH_O=QHoHJs;zLmS1w0*L_+@3LA)Mns;Az4y%BL7|m4O}7B)^ql?Qy}Pj66OvFK`_n zpeZ1IJM>)1Z&~n--hdd^2^Sg&V+`R2F93CMAhfU4ei|g*ffkdK2 z$A@xcFa~Zz=N@*Z8qRmt%v!beX5V3yA$eOp^%XQwml{U|`pn+QHmet`Fv4M>pWn&O zRorLRnoDUIK!M5K$Mmlb4(Xa9&{fX+SFq#C0>#QDi^cSs?z5j+rb3C4fim5kZ`$#@ zSF7SBiib}~uFaii5<`)mx%@IaJ%g3t768oF^GqZ1N8q_ngc&%eFl3O`BbR}9-5nWB zK}p!X?eBjU{kxX_d^T0KH}>S2_2x2riYnLr(cabO>|yrd@qnYKUz3T-`~A3_Gj9K9 zn3jbq56n^`wG}&lv@yItur(m8Sit{T>cB$JPqhAKtlIcVu;E|O>y>S$i&L{xjq35$ z2y2$t?ZZ-QheJIxk{~h?+AY9us~|sXHsDcRt`0A4+6;Gv=bjcr2~=T)5r!TZh{*Yf&ik_1Zd&)#fK%@z{p#}dP%Je39ni>M zZxE=G2%cWuBT!Vo`urs)BebbC?7;)%a+d1Bw|aj8tC3e}Kiu$$>sR9KrKaSX(8CB9 zQA~#Jn=ZU(fa_SivIm=1jVwso8z__ry#uQNbdZ+>1&KkNoxC)(dxYSm>*PzDK0gU7 zXA|w~Wia=UF$CRNW#(wt%6K7;8&s-Qkgn6H$+96)cM?@^xlGMQW5!M+)<(1fYx6&l znlAJQYY_7IXR=JH`T=N>pQfFE%^#F(?hLE(Du>8u30k(@#`Z+6Ff>$%k{{JniwO)t z(Ki>eEwfU0gSO*yQ@ya`r_U=@sH^V$27E}(a^i=W>a+na+<%Ks1n)c-UF`@I? zJ@RhKjk1NHY;vU+D|>`1c08uLUAR_=^2}8cn(@wcvD?VFA#qJ>85^D*JII8DTePXY zkkklQ^b1T3@SI$3&#aZrGVF5JSq~tY)|wZA?aHzUc%Y~`%;%o<@$7S`LUXvDUBo+2 zC*UTlWsP9t?bMZvLq`*fkIwxmyZk)hBM|JnD<3>u9CtiG`V6uF%SkYAWM4}FQRI2# zdM`~-n51U~#Gh$~zO?(z<9EXIcp(&@61V5R?4p-tX(^x(w?Cx&S6U#w;KcBX>p*sl zGH+Nsz$0Wg-~~{A)fKphv_6kMvB)Jd7_&Z{I|IVidjYA5uNhpHa;Wey{qTuQut?7u z{`o0fTrl5n^6{SZnfd^?E8S}o4pn^grQ{Q!9vmScN2^e0K!IV01Yz!u4p*<2vDM|c z)?#XcLH#H{X1~^3w{aw*HlqL{e$$$Dw$^=pw!*7omqBB93mvC^4TqNj=8#c!-O$w) z6_xV~r^K9IdIA9B{p)9MOx zXnkg2=o8SnFDfX6ks_#>`MZ?C9{74ly4U@@2H?iP`G*G1!~C@MMN*g;T)u&2t*&vW zOWJ^;7Z2>ScW2f5N)|Q03FJ+|;@YA;{PcN5h5NxezIUE~0_*rz;Zfe($*ANg*ysKD z7xbVQX{C;i?j_lDteDCrZE9E`LL^bwLzQjdvVy@>8EqP7BKrcpBO z=hHtDg9wop7U;o4c#Kpz2~J@K%TOm!TOBGR?3sFlRc^Iz%v#)&lQ13?WPOdFq}13F zZy3>9Uw_ngP*xZO6w#leDD>TQfS*P`7y}4%jD+SPA5`1Gmn*LyH1>(>>#Gp+92`6F zs_J*8_v;(T1p_X)MO2i=%D)~48=jp~y|v8O5r-zs^q6^m!vnU{rA??}Tcpg;Cii#m z+BnQ;BhRC|PW*aWJl*(bbscrt})109G3;{GFv)h0`zQ+MNxF3ec zbsFverv2VVI)40A0M|13K~lCURoliH{wKb$EnVA!S=jg5I&$y&PNV$|mHB6_Hkdvw zyrCQtp8IexOxP(A50Za5s=5x)X#du~G$bcxS$lC*$Q(V1zRzRxvD9WFf3WhYz<$f< zP+pJ3OIhLoz={UCP@jrJ*wbQ(rZtgA)#cW5K*c2tBl|wpkOdtsdQEmFJoB7`GR}oo zqnTxl%A1ymZ0g-hiF1X}u+ZS>u+vbmRn3`bMLC8nG$UWIg0DtNn>Vl(M;;qmOCU-L z|507SD$M!w=rs1#QQZlK%M1-=16YY#m4=bu(Xafl$VbeyB>AXu8ODsRc7W*(B`6^%Jx>FguXUe2@0Q{$z&wm zC}S(a;h}Z|FDFB4T_>$#(qwtq#D0PVJ>Unu5g7Gg#{% ziVyyKNb6=nOF>V?_XH3Ip-`$BS0d@1F2lM7V&*L+&I8wVO77-=ilMtKM0-2JEJtsG z!u@AGK~81UdnwKX)}APFP}c}@-y|(ixBtlE4Y6u z{H$S7(TFB45X4Z*C)LAG2?9OSU~~lfVw30OdXy!%xG?6rJ!JHDMNUj)u*?G1){>Sr z6LTN}W+@X~_>S;ofrD^RmlDKsRJpaaRX{K_Y_bQEh(%omj1Zo_&iwI(s1Zz+!~vU* zlw4F%SMTSGmeKYwXGk5SY46aGp}c^e2b#hkjQ%59Ek)c81i(-|^OZOH4$t}Z^%-fE z2x;CufoQAvSE7M_E=T!dz>>M7iutw85Q=gVySsUHa%~hAEZfhontY(joQ^MIfN->gqbgL(OV8tr%IO^Mhym zO;CdBDzMTiOj;fEj&AA}R#nq_sO9X5Qb)w-kt}r`9 z$V^$dEMb!YqhJnETgj8nnXXfzp%lKYQR{h>W8Xlv3tS^K=ayV6Pyl^cupNYgV|zg^~Jpw)i60k7ZDc$}D_ z_fn?Cm#gQ@@g*I?rBFewSwgucp#|t;2nmLidP$x87r)$W4b)1AkJspsy11+*$Xu=4 zNiQN2B`_JBieeH1F>nz2y^skbgM83*JY0OH*M}t`-6&A(b81)J5tXo_!2Z)N2Se7q z*m|ydn)T%9eplPHS)ca*M;V~vY%|J8QX71Kx}r95TG~ww*lHMTp=ZyphvOe_hi0d$ zr)TF{2>#HCdy;yVW>(Kll-3F#OwOp6^+Pp33?-vO32>6Is?2ipGmrX4%JI}|vJHPw zTI0DmsrZ)L96e1rADmuhi5XQjQv*yD)B`&6Z4w`ZH64nQN(l{}ZjiQFGDwkSj}=VG z#YvQ~nLTVBAt`V!2&>&NE>ZYMl6io>k$3PSxI$>M|^S7-&=T&D`P5GQGNg6KQa(R!u|Bz+$-H; zamv7Zr7nDCVj;J86)QULwZiaREH5CvgEiry;?@l(%u%Q+Aii%4E*obhSd197LRk=w zC1b79AMCn>=n(!701o-L)c0|U@*_)q0UJE9+*~r$Go0s^WZLoj&^}PM*3?Jy8(0BB zG^3b{yDsd_ilC*6p>-09 zt~$+B2r$Z!+HG@mWMB{8JW^F;KsfV`ZUH<}w?SGb%s?8L^(;SPQNL?w#9t2=Q_wAk zRKwX%XtnqL?$r)GA^oMLnZ>}-t&zmmfXe_ai6s6L(X(M80BQNd0|ENkP%CLC%QK5-2xhomLUE zIPaQjoc4jKv$4p*L&&*bK_{kDuxPb4|2Hl|#J}rW8AV*G6zjh7idZk+W319szqc1S zYkqfY#Zf(ZJTs4&c~1ctCIY;(q!KQIvbzTDyhvvrW%bzje24{V^17RT$%Bd{)7O6t zho$AZ#t9(`UQ~BnP3*bR@EN@Dsee`ap8Nost2p+Ex#VR2ouQQE=*sALxA~U36gC(K zLqgv~p%pXeP*DBW|HTXBz5$vJ#4Ef28BphVb=mCPW%u~!Ov!X)ON;2uP&>^M|K--A zl3+J^We@VdqZ@1hg$9Hr8ns@DFGSB;9NjJ5j^D{QuXqCuUYX(MD=Uud0Tm$UK>I4{ zUbu@r%3a+XLNPv2xwcbhbbu$^_v{PB_ ziNX5QdWKS%1tG9WRb^}Ko8M?=InR$2pu1bX)!UtqtVN5NgI*?qly3D1m};ny&M|Or z&q4hnG(7i)mR4K0^NPN*Z3f-lFXC|QSaS$hBfYAgem^limOlDom0nF3si*VrcaN%a zU;EZPsP{1!flHzbV$+D@lIV3fXG6XE@?45W2Zdq+S`(hTdqrdUr(?%D6oJC?Bz-6; z75TEL$m}_=VJDTM=T6n*t~rNPoUg#yHRFM%&0XS^_2G5-tOZDse@#V2k-myS`-7jw zw6{!YHklKKEW1YEePpxJFE{5$wDs=@yFKY9^|kRns0`dvV%b$BCK)U~WA@_s^j)L> z(5qsUG?uNc@;VmZq4_Y678tU`%AG)Vy_XLEh4{d%3Mb|xw$RCS7Wq$wPsbe^( zTUA#ZCwOpW8Kr?-xuR1N%qPIk^7WD{tHM36pqh&i%B^+QDqV@0ixT3NVeDLfw>~Xb zJEfD#DdBaX;*yaAw#-IU{%h5YIf!~4` z&@KOK0)SI*GK)&|zAD2Qh}V`{tvql`ny^^{-uaW!@AVe|Shl5r*hI%R6%KXfzi+jc z_)%oGRX_+JWJarsZ?NzNhP$mV+8*L7hP#F7ZVtHtOK${RliP6PjOB4sDzu6>1TC){GnL}K9btwV?P$wUS>xZEC zbhk@WvG2Hf$veJN7;USIwv~8aD?tB0UI1I!9Ly^}WI?gPF@Hl!byaWf|C?vGT|vv7 zeh&}25~O?(1#L+rgE6dkHac6JZaUPTN#4-{lx7Stwu75d|Ff#w3W0wXqs&eq?jD8@y})-wbikmWEWcP1>D^cnyrk z)!}2BTqWASEdP1a#2Y62PZsX~Sm@T59~vuoe{>J%%n*&q>Sn~fWi-^dWR@Y<9ersL z>3s0-3VoIz3>%12{`Uq7-zw2IIvRBQLMS_6mPCE&eAg*f?!VwBS^ti-{H|#PV=Cx! zENTZAfg&8;DZ9k%|4-N1=$!C(^@`tmEdXZ*46acb>>-4LI;?KRx4Mh`PuJNf-T1p6 z2j3b zK#kSi0~!iGFI)D(0Ev-%0ULYPi!Azmdw|VW0Gs>~V;gO9Nb#pO4rc-)Z7-Y2ooNiZ zN7}O>PLE5zs&rX)o6uosTrCE_d5W#8{EyR$*vO0yKHJ;$YjYt6v4q+W`sGZBdVOsj zux&7Z`~;mBayW562v;`|4z_U2+8P4MBX&N z+qbn%o&C-9&BPTX|B|o*?QFI=w4CjBO0PikpBoc04>x9%d_yak%)3jQyxkk(^|i8v zwMlo<^%rN4V)ULSs)SYchasY67Jjv8`w53%qqi3xyr5DwqBobC`ex9|`SHDz4?o5K zwbzk>&U(eTT@9+P<_Wu4- z*{`?h)#=sowQkcqN~Ml&_b_xG_M)a`HBJ&dTr_7qR8#tnQQci~r}I1v>rozqdid|+ zL@@>N^ed>2P2{#no*l8@-v3lKg54=>3gpuIc*tp>nrg!+_uXITz=x*aT zs5>`M;J(YrYg%I_-}wWPtATqxPedgXg$7P(|sOcObF`>O{@e#1e_U(H=0 zd`@-Of~!0&!2EaSPpj8GcRlaEGk3hjaBSD4^AaueuEViKK0uokn7F)aXKH%q&H!~b)5n=J{xe*&A_zu*o9c9OMxbk z0Jgm=`3WkS6-S-TP*&`HYhAwLrsR(buOH75Fzq|7*Hv?(tA<ZRS-M`_K zRN^a%<2T&4-En(Hbh7#ACn_wZ7=z#U!hz3dj+QH8pht@duC*faAsUvYH)(ILHnlTe z+9~$tW6xE0RX*Uqrx8}B(M(l+wtm!R^QaB3yE?2qW_##PyR9KzTSJ&GLW!?F_KD}^ zG1Hn4hxsT1@;TL3<#){ujh%0`;%259K4m@m235;~Yya-ickvT`9J~H9cVD==ztowL z_g9CUI|8}DhEt!py4g=4x4`A&ZgZGI~Aisgfo(_J3 zGqcsj6rACw(a^z(g+>L+=4`O*$KUz}j_#iSD(2nwy&J&eH$EKSfBwYmkFc{=7!6N} ziwA|$W_1VrQXIeW0BK`0=wK{Ew7IU?i-#sa&QoiUU}S#e@%|Jj3Dykcj2`f)ursk3 z?W#xvpT=0@@bnek#AGS#o>xvDaKNKjIHtQL?i9V1cZh-G)qJ z-u#@OWswqnwqjLc*XAeq?>qH_yC<`F6H65z_86u+G8cyD=jVzFWVQ_WNFA#84=y-6 z_s1)yk^988y&590ic2j_wWZ$d(VFcRj? zwfPHt|GBwS&CR(#d~SYH!{>^fyo`Cl<6UI&sdqga8*i>*v1|5PJ?`@#9XGeYBY%g> znGGYe_bvxBXp2+VeiZC)5aHZ#jL>M}(FD)BndxNh9rX0%xDKe${3$KZofzO;nht8F z5<-!X`bz(tj*<}hvyx={N>0cz|NoBgr$;zgf?P;%N@GloR%P6hF>Ho{1p_*uWg<@t zC|Z@{HJRiX*WH+X{nes_ZXD1R<|7vM*xL{KRue4!w2KHUhC&ua;5=Njn67V6`#0ca&U&G~aaoy)Z33@lqFUW|l81Yh>1Fip!NV$sw;l7>Pz&h9zN3 z3Y`k#42MXG)(Iy%$RZsf*sf6PzI+2h*uUcJPwt%!kc9168X6m+mz!zi$PTb>k#c^0 zn51av_6`3M?6ctcSVne&T4@5=9Brw^)pTkdrWQ<+N%hEl{5Dw(L$u+A?=xO3jYH zy%OveLNDaU55k5OXxLXnM$2BZFmF83AsrsJjz@8KJjQBt838g$Hn@8_{Dc_azdgjk zlH{)!)^Te12vv`(a>l6> zoL(CK`6W3~z!c`;;d5c}Z-l!z^F|&NWBn`~+gDU$en_ql1RA1L`iBwoScjkGh$isO&OgVacw=g<;ZL%aZnRF z^*BG_?u*-Tuh8{q3;?x=Ou^PIyNfXkULSpnz_e_^Hi=SOBcV10Bi_OMQ`g+~bqR`t z#ZS#qoGd2mpOw5YNA+esK4+1$zG{EL*&%O9*||^60loMC-PHW?Z@F!-_jN%&JhvD= zNbr06M$-4~A%mi`W)=%<<3@rMX3rpz1FH}3dMvlg@(;r4dDmm4XCU;4_xQxY?(|j~ zTsU3H6%X)yc^hgOt7p#eYgJHK*9LzY;9c7 z20ysI&_};mhm6YXH5*klbR9CRyF$q?HdB4=CHSTSxktI!_|lk+g*lo=^A{d7nNKJk zvL<7{;*@-=UH+&`;lSUD)ipktC6Z+3=D&@#&Y7Wp;V1lkboq@W`F6DrX7FEvch8wo zvY$P7>RGtqW9a(}m$QG_5bd7!VU#yHp?k0EFqZhV^Apf1fRqF&Ep&Gz93fc_4>&=* z_yAb(fdW~7dt`tASsv;`(#5&Vs`$oyq?ymw+<(@WR$5#(_s8|`5-phbKJ)1!FVUPk zC&Y)LPB>io9}#9Ql29VK*Sk=MAk4W4f*?D%z*!_Z`?ecY19|%YFtm3WgoMPj9 zUtoIt6t>!fv~`m;OnowDs0#@SLstu6sOAndH^q*={rL1cCVbnM)o=EGA_8>%>q)8Z zD2_xLnEJCx^*==w>!9}83Cs&p$n|WDH&=Bn;hpK|+kD&958fk-oezt<2e98PqaYj^ zcixTs`HQnRQY~n4ss#+B^(!G@;3;$Q0y!NrcDx&yi-d82(P5y>;DY<|77sx>5eJQ$ zANgWfB`>h%+Y*%gv%(IvEQUwv!GPTY2P6PF>@e1s$`yRW;N6T^JwM^THQD~cqQmJ% z=imM}{rN=t6T{6Xln>q6HSmuq3!iT3{e5n$@?O_{6ik0H9HlC1O5~=lSD)rYB-Y3{H3@u- zulBesgx+^(%t+Z} zVlbO=;U67~m$BNs{6ALn`z=%A&IBV!Q2j9W*uM)|fms#i6;b#!@5c#?`_2QO_tgr( z)IS4Yr@PN%(7B`=p>`2q4{vr!>1!VMPuExVw%_#%TW3Ws6{)%;kC>E=9o}4_B zS!eAvJxy>tbLPlzXWy(_8{zL2G_!bdadVcl9gJH6O<4BK@9Hftr!~(Chm>b5o==YE z$(ytM`PJ*Vx&ma`Ul4<1ALwT?O{R zsyLBeOIHHJ4-VcHO31~UHdTzV-<6l%SIBnGh8^wme-U=ZYQqw=6G&y>39qb5VvJP< ztgQ_a6mM9?pRs=f(^J0H5UZ}~JzIwRNbGr`+*t${|GBU>RvLE7Ki%~gIU)40)5#|| zgO%5;-sGYile?=@FFj^?CASzdzxw1EY$n5puRVJ{Y6BzmDY)-$sW-w$(dr)@Eax=X zgJzguRUtsYGP0QLh;3N<`Cv))N`qt*j5_1l4R9K)JLjEx&Ryf0z1wZfr%(R-VVs$| z6H@|}b?UIkFJ^2Xx&N?A)nfl^2_+Yo+<@wjv*bikTO-js2{DjmvO5mml)6bfG3 z0R=nq*QYO*o5si~_x)B-Foy_m(yk+xt#e~VymJnCPfvQeueNKs8G z^6NKV=qD9-PgiMXXP*7#%*>V%m))p<){_5H7XKHN1zzE}_6mcpG%Ws>%pFQ`K|wPg zV!c91${zU5{ks6)+Wo#(zw@*Jmjt3VN2Zs0(k*bOGFnEWlyF?n@a=Yw^&&M$uuIKp zsM04?+*K$#pfw>#IQ8n=?CwYp%rK2a>VB+8sWeFP%!@7f-l6}H%jDQ<=G17o77^^3 zno-nAQOM8@PrmNh!UnbcrmjJTgLa0PumvY9mafd+bCz|o4lWrkFb1QSCF-Dv&}be4{`jsHo|VJu6)9)RO^(huc=j3BDOfF zcUF`UtlWl(G7dWCFX5Ls^Yifjdf34JThud~<&w$HW`VvXpxPiSp~Ymq9~W1Zsi;gl z%rH7>3tYHUlt8}fX(lDZ^?8?%PYOIT80PCE0t|5X_1B!}yg@SGEc6R*EXIxwJ5J!i zYJmhBs;dVUnY~W8FT19jSL)sE!o+X4Yan;Nx)GSnqEPc%yk}Urs?B+987$PGD4C8H zi9}-6;{&ZpDnq2kKrRH?Y|W9E>j=;qA!6ZbFW}A9=CV7`Et!r=jPEl)|sg z_BpB1;Ae)=c7PYEUi$kYb zx9pRjHgL=@eI|siP=c+?>*%4@ktt9tz^C)_0mk3_Yv3Bl0uRDq^H`2lu*gqz3m4=5S@(XyhnEo%%dl1&F**SB(EahuD-UE<#DU|T(seC4%H2de_>$_Eg5+`L0r_vel>)I%{ zo;xz!n5S3m!|1tR5wTNPZN5e}PSJemb2R&&H^vQl4jHYwx%~zzdH624^ zH=?F+fxZW)ro1Xu9qbxmg3PpPv7T40l9y?@U`>kd1~=>(k*FwjmNUtN3_H`iD5>K~ zY32%CFoZrTu?q)IOmbZ1$YD;%4ErFHIKwi{awxm7=wLI%MO$#(-sOl7w5EfP$y!di zwU+T~@I)XQYB-6Gp10mK(;LNg9Bw1H7Au&f3uN$hm?aI3mq&Q~=9n3*ib|omA)Q;M z%+%Lw^&1k4yx(xdHCTM87|NiG$CZyl$vrXr1opzwoYBfOn3EOuc@tgCZrwHN*n|i$ z?!tn*_e$IZXwrg0{C}(9V3oi;~OqGYEH0*G~VGYVk{$JM# zqh}_QXG+1@cRec4^SNHwwx=o&bHMD8aU^Q8vrK4jJOI819hFnuZXCCpmTPUw%DNFy z)F7vcpJtcL+~E*3#;SLc_NjLKE@Db?s*;n545q9Z%pJ_lAIKhDm<=WP0O-yjmuOj0 zrw2rsrG`=_?q=DXf$XNM>Xj+S(yW@e9O*+E;;RN7`lng9YV`OC6j1<@JhG`lP&xO$_3p6+BTO#_zDOvoY;$%3+ph82P!Sz=sITU_s!ZL_I7td%mK;{YOGsdO+aKA|B%NKPiI#J*VA zdvVE%02W?z_(eyc^<&zjpyApVWw+(hz#5*y9T?OaPu=;O1lfDYcQ7Mr8tpwCnU<)1 zbGN+2@7z8^;wGU3!`=kDh8&}y^dI>N4qp9Dq%tWF`!X0cxi$)GxI{W*R-f|xt%GL= zwYjR;Ydk(=lTd=AroFU%-6|sc5$(rYs0<50v2E+vS2jd$)bh6;@CM;QhQGA8QB|tQ zDv$`FAdQHjNE1D-{E5H#O&C2qQgUO%L3`XXSJh5%Tm(lG*$5E!5@xuZ3~ebPZ8Ag& z9Y#pZIWu)N1gn2Co2js?^)M1ZxtobQ_C0;FvMMe@#W9+Z%ZhBEY9E^VUeQ~tn3DsR zD3REn2mWNRjq0auVd*EUMQ0`viA0r~UL&s-krb^Q?+5Ue!v+SmkzUMW9`qC{S)oJ% z11gQ&R>zX6z^n!d3D-A1*!+c@rsnki0N?rsBNa29s3KwQgiJ|SPBfrk65?V9KK<$e zGhVP^ThsQ&sZ3XgCOBxRGis=$$d0xi)b8CNQuSC;tS1B1 zIpxT8((Ev;LODmbb}b!@543Ir0bC_%P-5suW3Tag0_-LiP5e3yezfWrUOSYjhRih| zO?BY;q_kJzNv0wl@@B(lbwNGn@${rq>(DYvXj(V9_Q$~Hj3s_JzDiL6P=MkqDw6)D zmfSjODD!4@dM1s6&Rg(xgB+(a5XvL46TwvAilIJ}j}~wP0>UStd&_Ve z^s&;4Bl)*m{h@+sTWVjl$GOp084xLQQr=^tJxm8p?t;DW0^XSO^W)RcFH0UF_d-8VUDY+T2LWC14HPNQtuh z1=KV>s|n#|JC4eU@}xq%!utP2zwqnxP967-O_r)9`^eEDs$pSA1<}FmBUT`cV**fYMz*<= zWBa)E_xy!=J0`sG;Y{~INpb+X?3`IP20-Ffd+(`%?H_8rg-?&=6T-jbU_xk}e|+vh zAUV0R-h}q}>gP4qy2{CW;iA5VQ&OVg-dm^Gt#%)(*`=cSDBr5#8OEQ@A4bDkuJci@ ztk}@;X@>5KLw-N3n4hE2&dnXax6b;Qsi`SP zacwHYDC^uSQ7;9(sl3>5z=Xjc#e^?o)$qB)U8q2ZB<1PGM(%EpPUV($+M=_Y!C%9a zyyT-WOT?AeaEW>|GsUz~mALw<%WEl*1$})H5g2IYfpw4JeQ+g)A%v@A9VZ1d=ewIqI3^N zReiGb0XHb&W`@#}zapC|SGRAi!z>gD2PhK!Cn)j-Rr{;uVxn{=X78!E|}bhg!anhm8K-<-Ah#nmh+U*{wG(3=ogtvSsAbTQY$mFtd+) z=JYb+l#UU-eG`pRqI>EZl~*Pl8;wTPuI#Be{dh!*%2?)A?bNxwI;(Og59f9P&iznL z%=!}b7tF>}O$EQH=K3do?Nq$_2_?k)T!j^XoSd%2a4GH3c(J%cDp(p(zW0|v*FDBq z1Mb`G%}TYcxAlPD5e9&Bf--m!k_teA>`O`q6TxSilRYtjrW~Fj{1UVWV0?YUQ&${N zX|so1jUF3w^_h>4H>4$}ftg){%El(b);5K-ECWoaruFny#&(VidOS{_Ssm@qXt$P1 zDY#Q?nToT1UBSAQyBmJNe=5bz{(h;%39G+ghO^xCK%4wSmFaZ7DH)tSdujRSR5Wfd zF9=koEm^Ui44YzkpdOgm8CGUy%vO%#t9%P~I&W(CmCVxDW zUOmoFP_(W}d9+V7eZxQeaHAFfafs0O-lUW`2DGoe{M*)2uE+6FAUA0}pgF!0#;znW zIl0PvEJk`2u>Xox8m+~_;?~;i0%1y3d19Y$)yz~kabutN$PICX{;>g8hfQmaUMyV| zrA8!q;)B`K76L7&7*um$-Z0#?U?4BR%MF2Ha+}krmi7De2qB8yEs9WF!daO zUcv1)>DSbfAe}gx-CP(w3u4(A=CtBZg3`<01v_Z0B;!%cbLz{A%iK2r#0uZDP zPYh4fEQ8GpE<6cf6_dJ&Lze;ryhppK_N+F}K2AQrHx5bT(JeDmb^RFDtL$UcyLrs+ zMrLw)ygjj53ddaHacX!SGe6p&e%s$ZJ=mgzED#@+p^Ezx^ z|IB;}@VEP7W_1d;y+orcs)!3P}a@!b=BN$zKWLf*HE$aAYQIa-rb? zhSgT9Omp7y6D&%urbkWp3KB+NL@04utKQ__*Gy;s)GuA-pCOfl?9ip-( z2N?~z-1yiMoGanXWBa^h`u2|#nn=}=kJ9$&bFA1LeChlNXep1f$g`!dNDGm&Aeu>M z1l|$LqoNEQ)}BF6lYsgRRjqZKi=Ll;GXoI%8%{XYCfJC}Zn3 zl-$+K8*$9;zm|=u#Hy1`(pZ~>?D!`a2h0Ro)AG7y2GblWh&gPp<&lrVpMDy6AWDkA ztZP=-{RW`H?aY0(sYgL~ZE10Tnn^l+^9^EzFqCl4oUObrF=FRNEkbE#*gTYx7d8v&(!?T@@?}Qn% zaL^xaW>=STo77(jEn0KdJDbT|?@mZ-L(AvjvvQM%+R~u2BfcUE=PzfR^=x|L#zF7g z>8WlP(I{lG3G}T}AIynYsZQ*3qI$6?;ce$08@s*LXZW@v5h64)-fDgiv>m>2Vbv-l4av*`OtlN(rM8)!5e zU`vl9nVGc2Gm)~$L=C*8w&Qil=XLE^MoH490sb!UuA|w#55hCxk>qOmQ4LdP8;<{% zDHY43(qtN*aQ7m(vxX-C>WnMoA zZ*`huEB=W=n3qIXxE5ssoSnlcyjGO(kqB`dsYdqVXk~pLt*#)Zl~BZ`+C~mhBfG*3E@uuSrs&2}GI&3OMD#D=zXKU|xhC-!M zlPt*7E3=bN{X4`~WguyjtLr637Hf;Tatj_ty0q`p{CGS0_E3bfXakz(exD{<*~Nej+_tOlwLfdJHeZPB09&rC!bc>>7FTui8*yft>bN6UR)B z9fRy3W=WSlc)9@}b!ckvz)X%@Oc4TKpq-gu-^R;+HJhl-2Ae_riva>5k(|`{(0Aj`5vhE9v3`Q3Z9CNcTV6e+rf=uTd3h`@4H8+7BL_yQJ+3<^(%*?5$u995;lFI>((LG z_Vcp~SH}IMP1evc*p@ukAj#{6;C*GP#b}!6U4u-kC8gRJJ#!S6U)yN+s^|DdH)hul}0)24?uU+~AyfP4y1`X1R*4E(MX>kfFt$H&Mqi?ucI;xHsHO$DkEVbQs>J^J+lD*!tboVDXkoRU?H;UGQk&&PI$tA{{e|7UQ>sTKSLkmID*$D3|!Q+Jvi>uqRME{^oD z57oQ7r2vurt>1swhF>a^XzWyxPFhI6r>ah753iuZ{u=45oNgCJICbha*eY-dbqCa& zF&PyZp^DbcpaL)h`X`t6W_eY)rTiiaCQJiQdO$DH=B^@mP6W`XEBHZ+K(|Kn?F`r> zF$Pj^5IR`_St8G<-6x+ekDDkXoM|tlg@(rF4iyTt4mxdWeQ<@3;v}q9#(m=~{cM=8 z1O2MhnxPkk3)f7eh1M1w-^e*}^fPsE>Lf@M>bfonY~GmH%Z1I>PY14&x2I6tJ`6+!0HzHou(4FhRc)?H__ov0#vY9ES;G|2w3vrzjKspWD?ZF3X|6vbvqQq; zt&FgIALyA*>q?Gq;#9iiS(iw~+o5y|`@Vawv@6WsgE%ndnCPU15MguhlOA26I!w^wm1qrL zIoNEiHM$x^AN>In%^=-k$e2DS!~Uv4v|PR63GK-k?Nrd+_~$)Knqq?4R@-{Luoe>; ze~GhahD!ushcX8b>7$7vgFXxwDn~C|)$OsIX>LoKcg!T>v-Q9Po-KhY!LPGgG$7B7Nolsku}8_| zeiJYd56y6WWg_v?yLX={!6m2T1l%|YHf>~43izebuz~v%FNWQWt-mBTmXXJ?XVyiv z*`!%jJcEa_p4*|Fq}0*^7ioFN7|m*RalnC6@2m%?NhpK4zQlkQm@jl7|DWjuii+Tt2TErqW%X?W zZKN(z(vdd~g<8(%Z9M=m>q-qA9hD|umDX<48hgN^Ji23I^(2a0OmhN*7T*Z>zE?^P zZTd=1T|u-TuR5A>HsD4|m_VefB5WT##!T$Ma|dLy$`A(gIL)AiurxH8kVd3CO?IXp zClAli0__+(r6C9NR|vlPc3dk&hbVWP)5?I z-DHc9bl6Qb#&tZ2Pe>&nxOvl+RfR9gq&r-etGw_6ZCsNUUWcJQRir)kdLi3J@oeH7 z%?J*M16JEoseIt zk#NUKh0xZOkw>OXJ&UI4v?qZxt&?KM!-f31i&BDL&2@VJKh+X{G`L2snKPjPf2r!{1J`jM(^bFMZ>U+cp|lf-y{}y2$LL*DDJOL^$P0c z_%j|Vte}V93S^L0HRhnBs+y&Uip^us8@yhW+4T+XGy)@!Ynp zs99>w(DCs77DtS;D!HQJ2tW1ZFqv+PZDl=xL`>&WWwwzmq%~^Xm;wji>HkBw|VXR<|!$gp%{TB?uyWn_=B9SdXHN-FqMPI*@*- zP#dfEh0-gL?P8z%!&wAD)IJLA9lY#kcT&^k)8%HcA|n-SxWcsY9<*VdZW*W*wnFRWBoic0A94AD$}ZHCBVw6a7n_@n{$tO7lwohBbu?4OjS| z*g9E$Pc@AlJ*>-h_7H0VZ3IQS@mSWmy&Rm!wv^kS;hn(7t1?2pQAm4gh~{!X&_IudC8*Pz?g^pS4P`ITh)KV)bMv)L zuFp>mmKpbdMk;jO+f3cAK~|kZPYb-GwPpaKCmu;NK=csC>|UUq-~KjWNb3hz?a&|h z5$MU{xnCfbg%VkWg7=iGxUxN5&!dQ zMyJic@MJ!Bu1CTisgag4lD^iBv?YA5@)p9MlfZyUq~o4!s4kMB8zLZN*mr2fK@D>O zQQ3`J#rwWzq?T=-Z4!|#f>ty-?D(-s2pbNZ=x5#2<|I6fdt9y-+_dj$5Sld88 ztSed1^(?D9sEt83R^}vsmvOZIy~9w5Ih)guClx}sG1@8Z?-p79MK%4cQ~89vlHK77 z76g-8<*&6ZI&Nj2aaFh4Z^{{6yBg8x$Bxe^6t5Rw%#WE!eZ&aSUE?-`-#%EP@$U?~ zPx%S_Qm*#_nm)xA2`ZBTpdI!U^3Q~Qlsy8iYrI+6)N}+GK6?`BUb>k$H9~u2`{w46 z11Y{jYA^HHB>CpiDs5BG&8QE2)TrkzzD32YPt#9Jhfc9`G^KMzVyiR29QmCE*&pjC z@9oI`?B2supeFE_6>yw+1(Z4WwZGP_f`8krRok2~6tB+p9@;1tzc*0gT@S(o#ueL0 z_8omIS7%keRYKmr;``vs=d>lrw2-fFYi4v>Dq|ajYqJtn(YD?X|JXZTje!w)0!R}OBoIx<4qkRtOj z3twN5v5gNm6*4pB;j!X#krA#=d*oUO`TCFh=#RtgZE-*$T$PE`xGtbX#*O+~`!eh>6B*uk_v-%+NW@%va_`gO~Z&b{p5;KbwU zansMYx(cImn!`){3U(mP9h{D9yW6zU75h9w=nCKdyF4?>VzppZ$0#hZC`KzCOLWLr zxFV;2qbZK(d^u+`Iiv02@Rg;mbHq%ffn6lZR`9#7)mPkZe=}-!3ctjR{vt&|EXZO* z<-H681KNv0hvXNpP{%4yb-THOoLT%NAL09y9e3S|zy62Vl2cuWze``gZC_$WUfpX( zgTB63Hiw?oLYbaUm3YnK*ha-URcSdpFw~sef2utuzMmg0ouKw|bc>`B#Z1jCGR`?) z-%B`eZpLzu8P}qxxdA`W=z}y{m^rdSw^a`HZXF6&lgl&)=YQSLp5&Tggth zyFv+=Es|TRes(_8b^3)C9{x~zb)k{-@7?C^i#>JR*D(v47uPKueb{&2Ev^AMRzCbc z@_Igf;hUdoHJkB$-&GZzGsF1y{_*wD>)i%;xmv~b&ie(6f^^nQ(W_w7-NG#Ae%s_* zHBGA9k*%h=`wvM?2R8-*30d&kv1F77wvlGV&G3tR)vHNX{9_~YN->IQv& zH>t;bb}(>ruR3ZQN4K4nHi9=SOBN)+5U!QXKk_gQZlA0*d-rO1LczIw4gG%JU7dGZ z9Ot|B;5w#HdcnCv*k$=~z8b=Hif?3UkFXXRS!|gf%w@e7Ymw+W zeg5(z=|Xom=x5}gG&*;v%iO4WPmLU2?5UcWRL}jF7EO8k`O9y_q0AJ1gP&2qU(_h* zsN|Lq9ey1tf~HyI(blg5uf?H$UUq1uKb%=Wc6S_dFyx^2KBPVMaHQ%T*ZeWU$Pc>Z zU>xtwr*Q6~XuhtezsW}jh=88Aav8t0r&;D;9zSi}G3e}dba(B0YI?x_yK0^mz+Xhh zpi?XQxojKocakPT?bWrAFiMUFM}SpLyTORXzfL)M7-tb zca3wSF!%V*Uas#};iB5z0kik8aP(&wf-?*FzdbAxNz`L}{A-Wu`eZPc}>6&`;iUFsmcZT<8i z)5!-@%pLGn@LuUKxBblmXVnWkUmdX{5w}9Nhd5g!@)E$L;AYD@H8n>Bjb*TYCIqkqr2gYApC95J|9-%HT%DgX!VulYrlJ`7ByJ;z5Qjk(`sgkO- zZFC~L{{5XHH#=x@6kqY6RQ{&h#^=+A&T{T6^!iWM3ct+|UUS1cR(|@B_v~B64}~i< z)-!Y}xguylT=b!yn83mpS8kjv=z;qP)+uXB6Bb;t;lS7HbgwQusGYk_V`gf!{enQ7 zv>`5UthTHjHpdi@l)O=NTMkwD@>)zGP_Hkj{Tdv)qRSPRzNe!_OE%p51}vbj;Lo(n zhi7HpHN@*Ux`ROD0o{%@IWB(E=avtRuCvNmqN_4OJTPTEB!v6=NzzrF!T97nH3|2g z2kmk3NlO99cuaZK9rWrul48brf}4Eeh})byRy$;f8#mlUR$3mT8?qD>v+=bqk*y?Xse zDEz^DwO`-y-bkE%%c9obO6rC_439Q$2OC)!yPmg810H!Y3NQHE`ge7 z;G!Z294EUSn(x1cg3unLTz8CYc^jC2vz68y(4HP+J0y%~4CXkwTv5gxx6bUm9#~kR zJmau02~UcNbfI9ZPDkYp7l*flXTz48y?oV{hV8W6_!9GjUzTmd^qbeNC#ZBIFqX`$<)0^J;ijYMw6(5r*YmFUCy zFG?xl_LEU;@dU7ke>AL+$F8WK7(LkbqscTeJTymyASo&7n63~|r(?DVZD@F>*K5-z zvNv}$N*Jh+gGMPQEya`)wX6}0qIMbml0FLajMI^2Mzx87<;T9Sw#6MLh+ZzfVb&F3%vvgXgpv5;eGamTx7kP53z9# z$g(eLv@B4lRAbpb@t@`wGd{_cZnBg+y~vt7w;DaMon52&x>8_0D*tl44?s| zm=EW37y^m;a3nspjU#x*!qKCNd|i8yMGSd!_S{CuvfHOKCP%(qLSC+>rlzMnE-Hc! zQSG|R{`w6F2}HeI9}WX2X(&U^n`Q=_=?Z<~Un1q9p*6|Ad8Er9R=F)`iopr|w68XM ze8M%C{NEUZKqUUqQ-o@-Wjvnq$OA+WhcQ1-*` z)0pBwKn~!X<1k!`iQ%{61PB76_g2ArvWg?p0+G-0N2FM1LG*>v`(_O=uSqXd@=g;C zX`UW%A&NsYv)99IQV)W3*RsX~x@ecz2?-SWrI)Zadi-#9`Sed@Lb{+pykTN&?rjC< zHA$<-gQ)1MvC>s&A#(sF56hia8z4Ar7JViNE;x9t68R}Pb4VOP&+mT<9%lZhpQQZA zL%xpz*)PKEeOe3bmdy`#@b|{}MaRu4e2aJ0m@VA@pE0$MrW9iD-zQ(bzzBEP9CE49 zZM4e|HWyl#GgKHj?Rw71X{+}Xo9v>`j1p7ZAG-BNDSDiKHrEPcdcR&(YBtr75z1T`KA*R*)K3O#Bwc4u$SvHTIgD`yk{AyMF;gsXp{sv5n8KqptI`K`$$&}jk8fprudU41;6QXzS(-m1+ zSmR@;-69sxL%T~2b^=+5324{u zR-%oVkuj`0yGIAVi3e3f^#sM=N(+~|_+PrVPM1Vu;LTmbZh6-kuu}mv^bE8u4}1o8K#azY7yAE#8v!^Z%f1>+ zP1PADBqnB2`9yc#NH7chB~dfO<4^(i%!APk;R;W#{r30x4}pZ)t3=RFhYASJhXF{h zxGMeOoicojA4;#e0Vw?LvsKR6i?41xDXxF;%jE+9r37Dr2%?uw*QiVU?4ZnkboSf1 zkT^1KR<72JsBRE7$eZrUHm*hCE3ONT)pH8Jz0rHWyza7>jh~VRbKxfvRbCE^CmeqG z@kI65vd4V=j=W42mi&YDe~9)zeY58>fB;V}nF}92yFt(fS3$YtnwUE{Nk1>3q z9Uoka$20R;l7UAI@Luo71}B)I9$N43ySwI49A-=}_m|7X_}pyuq)C%pTheC10}+09 zyEPmAQ&pqAH=F&(X7fMS5bQ`J?7^Ts>HM0*IuTz3cTzH9C34zeHud})YyMhc;0bsc z`~0FmAJzVl!vX@EhgI)F9Rkacv^UVa27Z&Lt7=>>VcdcwAH>8s-B}y_&B-(8FE9Y6 zT;e~Xt-b8j=Gc(K2>39k%yJAo%!VvdfXwhc_sfg$&#pz!6A(Zql%#rv&Jz~zF&nTI z9{|CO^J4cm3IT}Vp$p^{!Ex@zzsr-m6n+vziA=Kj+Xc!iF%h;ZCeks zLE)+HT4hnsl46TT<2I)ci?p_~B`E{momgS_A4cB%@|kq){sl~bbSHDi28lmpXn#8R zY5Be$W%Q99*RU@xFE?iowGUH+xp{Jai-D;SrVua!5Kn?Hp=36Zg*Kn#G9QFiQ4{^% zr8ToTA}jg=Mktv3)zePqPDp@YdVg}pA1xp#yvZK#sfj5ZYG1d8tBG2scL?A5?DP%Q zl7B_SN+d}n;`R7s>~uqHcI(EO`Dl}{WlP~sOsqgMTxA*|NU{!?I^T>un%cPfUw0szepZcwl&zUbWF zP;au&vhd;($Mo-{Mw?CX+K>e;l`+JQcer}3hB~x&hC!y!@WuY^gRt2jBVA)ioKSw_ z!RKxfs2=r4PP==Kr{pwzf8U|twxB3PE%R3ugjv*s%$V7TH^(NK*X#)OH$S~jXC4yW z_gx!e$=NzTDfZ09y7?I)I*agm>5lzEg`W>g0L3^@Udj!>|TUuPGe2$l-`AB5Q-u&K{Q8&rPLZ|H%$X(_A zz&JCp`p7P8YwNp;)>xqLIC`8Ti&S>+qb@OThg~kODld>dHnRUcVM=amm25~Dh?_P( zI6bxH_u&)LF$m9C^!JD1rkmJprQc>L$akZ_%ixe#WW2_M0CDQ1C}Dp=Q4U1h@>-y~ z!D^+mRoCiR$RdNu{#G_c7SanebkvZ@!SH=g%+8afpCD*~>(=DUTN^<#Q^QN~&l1pc zQ2PCf#K$`aKm0g5niLky62MjGllM5h(3<1P=NeGdGc5*slP=J)Z2YSx-VsYHJ(tJU;68jsIL)I+41C&?CuT9F-6H>TF zL~^q%o1vtrF*UTVJ-yK+B>BP1EDS1ypTiEV@>G*OK4xSDCLQ4X=)@S;P*Z?I1Rl ztp4>>jT$JWmN>p;gOAusf&UT!1Bc2Ty@ntzCY%CaqTF&AtxU8}(Z$s5Lj+Nx@UFw;gK0-mI&% z5&0l}?+4|uTKQl96M^}s>o*pSbt8 zLfW1O$Z*GnM-EP_?T^xu8)(cRPPgJM%WfZUIXbeGux@t{LP2kwHpz4Dzm2yPFqO+T zD{yO%4%{r{)LkhE)rcbn*xwak|KChC4e_0br9wHWJF-7F<$k=KzR~luZ#SzZevS+t zScGa3@hn)h>LSd@%{k)@sNwCN-xu9E?0Gp4>KYyVb~#vtk(UoUs6Bm-Q8W(44{;o) z+XJ+Z2S}|)A(*n_WtHa;5=D6*b>Hi%mu=?|BchlDt`dT_r`pYS?inb!TXP zr!}}@AqK^pj;wqJTlYd+2|wpdrK&U+5X>ohF3ZF`IhP5OZhYBoFi;@j zK>rKC6iPccEF70KgIvO5cm(W~mr>cp8CYPFr#hsAbzXA@aa-Jn zD1_v^$*|{Ufz)J@Z4)$K_(5ph0#>3$!(BZ`4unbz5G;y=1mVFr;L1ohEP_Q`f*6k* zxe;|)=di|NHx6?EHqs3`Qzj?iax6Fe7n6bpaS^n?koym*W}nKvN$GI9q6xaSwzvsy zdi)G1G#X7x9D+OtoTAF6qnW`Dp6j$0t9F1%ngAlclx4eX1?&aN-UlXl#F?oN%RRF+ z0#q;_gqECgUPMVN1hI#R00DMbJuxLnd3xlDAN+8BFcFu4j9nPsM$3{pH3yKX47uU5i>uu(5j`dl+J>KCMd0QZziMG}s)?Hy~~aywp7C*{wd9<(@g&7vIs3JWRI9ZUBF)fBviL zM8E}xlt+2M!Rz2zyPt9T2snmY_w@8!);ZepGU&o4?-(Y?(gS9|(Ao`375e7Gv<*d9 zHb_ZpvZ-M8EMeFg={@SfTw8+%y9nd$a- zoU}k^*=@~!_hr#;`4-s}U_wfyP&sugT1ToK$U=IbN>OOX@g&JY>B(yU!j2Gaslc?= z?CIkg14~H9Ja;UDoyzs6zDP*}T3aVTBWIf9%ZByyY3_i}Qo>HYFTn2063&xK zDM#^PSkP;v|I(A?Ja`eDT`!8_+XRNfbIDb40q=-bfyy_a$P{pCbv8W0ytId3rcmK_ zo97B*_KAB%)kCOC(X1?YQ4yo3CuNmKPc+%Wq<9w~{=gX7L7{8cZur;PIDX=hKx%vi zEXeB$q36t)qt0h)c9`4>eRhoP!iVs=oHl-2Q&CE@U%i%1BkoPo4C?FeFMjjO*WbT* z^B|9WgyT_i^_v-a?vD7?k#p|wu5;(W)&dI6G!cuQ< zreG)p_RG#mYRB-;i%@fCzxd64ZuZlqg!=RPBrj>I?5=hBzbxpt#fqRWAs4qR?Trb3 z5mKC&p6<~tf?2v+RPOjukLlc9A-km3*i7UIyFc{(bh?=!PZsT9?dg2*^(1Djr{=h& z%$mgpjrJ{>HB;)vpUs|ctwghcbR9ww$5HKF;SNGoTGp?(Y4fxI95iE`^(_Rg#lPB1 zpDP^xQw`}4rBJB0US%@P%{yNnPH|O7iy1T17F`c3~Cy)r12@bBm zO6&MipM}DlW;jf>F~Aeg7Uuye=U;}ET9el`>#kiAE06sQ{kiYQMJPl}a0&5oKX*$m zAQo^+>s!Y?;Lt39c$707guetc#F@!lT<|%r;Xz;{~xef}sw1MLA)rMb-b|4{eVaZz<^|FB332t$X!5VwLff;0mR0xC*~z$hTC zC?z4?U4n=pN`neYqYT|Col18pIW!FMu0ip>Pu=I7=bX>;KEJ=vnLV@j?6udmu62Fm zIw>Ii+?fMf%TFK?>?6T;{J1#Q_lx{-ycs9r_Lm3Dm$&z>jCud6c*TbLL416cMn(+U zzGv6?93yiyy^ZPk#}MN$Xz_na!}*t>g4BF(9+@utYH!>LPizu`SP?6doXSIIM_|!k zyR|e_3+PTY_gt2!vMUewR>8>hA;l(qDQZAQQcLQwQ@aZM5PR>boX^n@F|^pv$g)Ul zZ-#lMQV0f%3$7F2J_hXM1V2sWk)BFmjR!QKs;%l@u-Xaa_@gxYl$G&?6MY^jpu_m& z8=X4d*d}1W70_WShQKZC>!gPuZ}If=T;Moa|Fy*(jjRN@vzKQ7mM4~)5aQHH zdFZ4PRQneY??2-<9Yfe!;>e)vY*vxqrWJYQA4mP0Xgyq zegLXzn5x?GfgR~(`r37%%L^4C8`FSYOv#|x($dldNaVL`L-;Ca-M2m__TAe~O4pB7 zJYbB}@DU+GjPk36JsA(N`5V~l4=L;$_gYN?tc`Pj0-uyUzy&^;OJgMQ@$nO&3sKfm zE8yhT7z8j6jy(HoS2{v&^@6XECq!v zhiNg4rpG{r3_4uE|An0V4IvUv@TUda6@ZR+{Z}p*1L}cWmlg6)_6h&|ngBoQUn#i1 z_s)Wx{QkaS_s zdVy6gdZ`8Q!hU3qzqrp>>wr)VaLp4+RS9n0DS2za>i!Ap{LbY7lLqT2jB~TTZ|S77#ZWwR=(FF=%PhBI0RhIC?!0X0mxo9J|1qN&c}J_e}2us zlfZvn@=1F7>&FTD+f`H75B8t2u#kA7xrN0LAEVa$>eYj!AKdlV$!EjePOVaY!|n z08RgZ5ckh>-?U}y+=dfA9Bns9XTM8(YMpiY=*zhAc29uupCH;qES^k3lK%xd%>ULT z{eL!&ZRO}ooc~mC)@*0{3d@xOeI`V~Ba93XJARJfzxAUy9@xTl9E)dm`WYb&kK@-i`*k{UECT z7_&bHpC6*2aN*w-Q-4H&DGMT~%tJOpuQ96EhAl58yCKk?gqPugvnE5xrQ|F(gej4Q z!R805(nZrz26buDTrpn|QX>mVH(ywgVw6KaBJJ^R_-}XS*Zztcr4VrpgZ~au$E01w z{|7<*wtN5@5Ta@)|V_*A)o-P ztE6w--D#U7RsXjM3VOM1M$FJE^{QB0ZmSrMM)6g7vAeu^5TD-9&f5iT+O z2DfO1Z&Vs_)#)dfmP%x57Ydt?RZwYpwFYR^Z>HQ&;L<(|_7%1yb`86tNo&myi}i@QxWM`f?IyHqGTfsnG*g=WP^L8S(i-D9EJ$Y!`3rL4?c= zXArv-$|NOB49y<?yY<-hjJP)C)e*Idx6h@$$b2n=e26y7JT z*G_+0zN56W1 zD1Eby?HFyBlru^b9Rt*hfXV;=W?^vcMLQyAgv{QzPnYXaj?YBxP;Gx6g<<&vgtm4+ z=>L9mp(T)vZOWt2a%63pO9Et=lJ|bGRew--Rx1h%3j;nIT?ml7&qV287(V~Lajth9 zX)#96c{Jm(xm5*nK`?c)&}SKepEnza$38j-M)H%ue-)*u=HA^pi*|MuOKq*M#hW-liOTC znRgbhaN{vy?>|>P{eeMyB6>Qmmt&s06IA2D?OVBwQqFe$z>=}1_r1DV&K*EQF>wT7 zVlBXq0fcQ08bA8QT9yoK#C-5_CxCMNYsC(?8~1UwCCV#JhD?`4_qnSZ!RO6eO5>+M*Fb}ti z^P~PjwhZtx42m2hnm4DU>T##k-eTuVVTA^Ii0Ge#gwV(XzJBlRccvj4W%^&E%2>nA z<#`ST?Wg-6Z@$;^7bFqOdRJMtHnXZyc&OxPJ)=}GsjE?6RDC@o?DJ@0@zIKdhS8e= zppU?Cf&XTSS-Ce%gv6A6fejE522BHqm$1i~2K+G~i4@0(l_y@CWuM{FT--;?y#I@u zD)ce0+7IFDUN^ogp1I#X(*2zqA0VHlQZ*QJjbFZy!TFq6GRFnkA`Eq8kZ{c8O85Ab z(H~V&KbX2Dd1+s7?%?4(?U4Fv!}Y;)(8`Oha|uVpLk{L&KMdmBHMy}BFa0_XK~y$- z9V{|5KXQ!DIon^qVY$9s-v#*1`VsLKF%P@0w@mO@SD z3aiZEY)5>K^|^}$Hlg1e-_X)(JiXQ-X2`soK!xLSc(5EoC1}0&VrLAbS)*?9y*-7h z5|4J$!L#6cfm?KP`1RrSuNf07hjY-{7Bf8}ZDFgrt2T^Ea!)nsBPA-^uwBOR^Pfh2 zm~BoD1cJ~L3CHgkeL2`G%0)rz)$~D zeLIYCqA^?tTH(H3Sc6=)FsP}!W^f>}Fgl`4{Ng?|vQ+tOX<28^ZmRU?VRvA$O?nbg zYy3?f=fJm&JH!?y${Gi{bpm2ql);q3aKR%$?-MZi-uvEQqQ(zHA@&xSfXn~8OPZ=UF+FluuRDHK;hTD{u$xZ!-KLXG?U~!<&azQ124Uj)lFgZ;}4305TQkkdOwG-%aMSnfSbCVAJ zSX~3wO77x;|A8n`-r1h))76#axxDmSI`>bw047uH`j_jz$Iq@-gq|aHyScRDd?Nh> z%Edn^DGNR>e{i0%iZi}Xw_ZNbJdC46|1KUiV{5>AzwWcaLCN=7W+LuC-$<-& zCWsl^Bezw>Cl}??gI~LdCaI_?>h*tol;WP$!hu#FsP=I*>DH#WxmPe z^PQb>7?o7RGt6{ccse+?xn2v{TnQcQwf@PBt)xo@yBU=hL@Xaz&~aU(FiGOR zb$c>Ozvr2NaY4JdL1|QC)aBDgadG{0$XVyPqy(p{1gKG;;iY(Xd!CZsp0VM!Sv+Ay zW^-+Am$D!>bStZ`sXM>J#YDZ#-9hJNmS2JYXUIJ9Wl)1Q3;w}%pja?EgiY$` z9CgTfcwn;P7|{n4;*E9s!~o}6Xqzlth<#xHS>Yfu;jo<|P#14<6Wrf}Fdko15=>zT z?B8N%!xd(VD+KqF&EWqidZ)r(Jotm2(B?ri;rRji2y=~Ch~d09oR~M=nZ}}Jmbp_s z)52q0APpXPzy9tg#wG?eAr)q7Cb!I}ZTKfxj2ze~D$Io+wzFW%|69LtK&cD2lQ`=U z1$@He`pLvLV9D^qd1aM6O_5N7E<(qgh7}3L={A}j#@2+`W^vPF;LDsd zlYEX#@}Hhrbu$3v{D%=8kQ?>utk55|cpGH_sH0~v_8I4d%?W_^D2q{1v2hycHKTV* zAgqj(<{6v~y*{xDs?Q$cI8Lw4cNqZ=W@-&c5o%GPm=5e$O*-;hPu77-Lqo2KQpa3Ko>61nt|1_LTI(elE#_z!X6Flp z3JdUo=dks~MfDo+-8E}7Ewx3~Q)UO6Gcaws`MjsJ0+PVXM_?BLNlP`ljv>D&HR?+B zuYjhqzJR$b7UnX6O^6(r3(eo>?UC$sP>{B}+4Ofxvozb@JQZ=e7&4ehzT67)Gf{Kv)3gy%m*G8>S-%$PFZ~MJq}#)MypOaHyR&Q zn)_Mwfs%+gFpSGQrjw))t&z}->j6jk*J*qhr;~o$4yhVoZ3))`dF2B2JLPzKjETo6 z;A{JAF8P;D637n$II+>-8#@Uq+Wvga78r zq9n;W2WJrimWHX`fbdgoYSil0lUiQrpc*6qDY#)X*TLUZ;$iV|kZDk{; z<}i>y<_nyKHZoQpOaN(2u!Zzi|5UMv3qLhC0s>Yb=sm}Y`p2yTiUCg+MuQSj!WM%N z_k)Er+c`EX)&7Q?^f5%~(fOrf z&14fET%X&>y_kKQNAIm?4Ib~UiuHDhQc8mRtubVL&J(ApOLzP=a*ITL;JBJuSvtk} z$mY{DTaRUl^Hf7IvLx>D8wrvZL!r_v$nyJb-yef^vw$!zKGh}j4)JsQEQ3a%{E5&U-^U$uy^R+8t*xmBVgypBU=ur_ z@lUQ2LmiWVp|NDP!{{BFl2RAVNqt;`F#Nf~gefMnsnn)Wi@feBVA&GXh|18Dx;tdW zGHRw8N+)RP%Fv_BwHBf~z;OG>iPxu1Rv#ph)=Zok^~SbXnwei{pdV{aY2Mh>eUjLS zQJY{oB*e5>RTN9!h!;N(NO%{3mw$QQ0ig6ZBFw&K2iblmQ)FNYliDiEHSJ0fWjD^j zo)!$wkNNnlvgrvWYd&p!ytsq`9|fMgB0f0)5$_W(U!vkT(;d=uU;k8cHSb zS%Q>S=uK3kiB;pOheIntow436%;-sL%pl6R(fcc4ulLigfbPjbjZY667o5-+_ek4a zQX9kG+FntsJ|D{#JR5LPJq=8>FTRW;BX4oFLvId;dRc2!p0+tU*e0?nL%n;bF_(BS zkl0`y9Ge(%1iZcBI|Ch-guvn&rej?{gIaF@)j5oohoVwK8b~6nhbKNZfEB71u!*V4 z2by)_Rs`Omc8$ReL9Kx90aERP_gnNvnYKYEs8zk;Pu@D5W@+=Z zop@_@zy=hf;0?){S|^_s9u{>6EUfxT2c*Y99xxpDL5bYxqSjO~a&L6OfezNky*N0l zI)4DXlT;W6P(Fzg#-Jv`G!=2poy^Uc$Z#t;DX8zTXdjQ_^~ zBEa~@s$f_uo=ZTD6;=8185R|(+p{q0RRwE~zk9CrKlKSQ5qRwN{sz8LLl~)cp*e3k zL>$o1F=C^}5M4myRxO)93yfT;lqHrvt%BJH)C?}6umsEH;mAichoJWeWrvhfG^C#MOlw{GgNmdPJ72w?g9aXAPDd?=iQR@3035*>^MLS4q zqYh4ThMv#t9U&;yBAIhVG2tPh;Oou;0nha;QFnZSd+4f@Z6`6kdlEzHduQx$E9)BUT`ECpF5N5YBfs0ub+>SEtizz zH?aBMAGE&xgqJ#{Qva|DcQfo_5BzKMz9DfHgSs6{-z9t&X#bRQ#EO)P&j8=>ldmIr z1ZSSva^R(;V@#-uQa%w+i|xlKsa}=OHsYvo}CHZ9KPx;bg$ zdjY<2Zz)&@>z9phJ%+qSyr9R)gH)(|0>%;JGHV&Hz@_}sNW1i^{4Z{#d0hwJq>r(I) z1olNJkn{!SI&Z#qEaP~dG|z8xT~U&xRVjuo(c`cl5m)8jyNw$Rj|nG(PMvlOulWZ0 zn}i_B{GfA$J6)A-T0mgNx4|~6Ke5(F2rB!pwpWuU-HLx-lCi`2U3bUE9;?l4K; zXY27{6xvs^bwNNu@SaU2kx$olSJgEuqe+15UZ50ifx`peR&7ZbD+q%6(l&r#_f!u1Po3 z+-1CwD_MmgLo<@&yPLdJ`FV|LS zGjhVtsenjFk?4u>{|l%Oq4^rz+Y|*z-QBANIR}-fejt2`TLo^sIRfk?pdD`vDe(OF z@Fn8g*KcK{WcNUYXg^v?3h!#43g-(;MD+`RRG}V7j1t2e!^KF~sbOphz+Msck$zTb z`rej1piu8ONJbWHf{rh~>+LRjlJ|yN?d!+_>USriwW%9+shE$@Of6u9X?cO4&T7ul zo|UzG?Y>X(`2~;G2kUc*4BHMZ(QVeshnqPbj6_*rI^HNc+&ec3>qu}j9VXEqPEyGY zcl>aA4^L!s*loK!FWs$yF0Jvfd~5LNoe2J!b4`sX3US5I5taygdJsLrnYK?KAK7`{P~i1N9$RA16a-DwIVhd=bWFKvFj4w+k|9k$dxs z$vE}Fdm22Opp6M`9!GG<{r+;`Y#3BetRp0sZ1nm014BtlVN8L|(Tmbuhq!Jf*IILH5_a36We`8V)rqbo$ z0K!M1?EQMbo0+q6U8iX-O;pRn9U=IKi=yAP`p%&4d56q!5F*l#h#$oJtcn`93Yai= zJg&tJK829-t6^Wm59g{kx*p+!_#CA>%y%01%=9_U+vQ-Gt!KD`ePi@y+hWg!_;Oqy zS0J3`3>F4%pbCzlmw}NB2jMsz7PbjG?>sFIJ$GI8aWxxEKri`fo+By}g<3+GxB%zC z#;+f}&(A`K^}WTgY@xHiK;6iJe!pB*foGrn0=Jr$D$LTV`r;14_$ZQwwPC+N8lqyT(6TE*0OgR9rD%M z_G}nk&eVk%jO?CTnLm8;9YtoR911u5nZj5K!3(bgcmz(pF6A6(z~)(Wm%ct8Xo@dW z8+J8v_yd(H428#>`9;1O=fKk=wJ8M2UpeK$firarIf(e7E`f{Sg;&-Z>^oKXFi6Gzj7MOGo;5c98o8hLFl~U?>2-Qb z&MHnlN7TmIdB@*Y9V*sGL=)6}f%o0e)C2ly?JqBz*j;PU*Dsh7tvs}EtDSjEe&Tdx zTUba0*z?}fp|Tz@frf*Z?PXDv@>G)TB;gkt>aS47=3U1L@2|ymU*rpx90=B=H2+xC zA$1=2!zQ4C{`w3<;fcUVCHaG2?w=TCoDKywB5Wi->3nW^w?7| zWAe%Vxkm4rD`QiTlvk$FKx4#YkX_~Hpi`$POI43Qjj=(L3n`#O1O-f9Bs|Z7V@M*? zMIoX#?6PuTEzfQP4X3UT?luY&yD;oDr<<1^NaesmY;X?)K^i7QL~_6m4|r1M=Gm(g z5b`%!4dZxgm}csufw4rrH;-m{imoJ*M#LJ3$KtIM$!LI9Rk~F!M&+K<+XsDCV`Ivz7;h~EDcl0uWf`>Z_dG&f;C z2S!8t%{q}^%V?7!c!=Y%v3{j$FWj0yrqMoTY7Usb64i=^j|UOV4<#OMJP;reY8~Q; zF-C{G4S=hjN{vBTl}Q&}#B1~x1qtK0vRk;J_&x`uI=s)3gV{iXcdK}X^M04dYcSlo zH57i*aj__;(}`NEOhy{qYhOk)FJMl)i;@0+kG z?W}ziIz9~JPxVOT*DwdW1fo}t4*PaifW_<)`~Ifh(aoZ9?{%ZmiLS;bKdzj86v0fV`(2x z;So0O#Q7MWPq@F<6@IAf>P_H2bS~q1OW4H6#Dg0=BwHw}qb;i%OcRR+F??LxOyi|e z`hu0iO{x2th=#b(SnKSf$>3FB?(dOC)y2_&&#uc4wN7IxTHVIL5sJ+)h4BOFz=}y- z6{K`@KCOBGXkJk|Y{_+!;${6d5jv)v?`ZDGmBn?p8RacZ; zs_hXvDC6WbQwizpA|Sf7*a0rJJi^(OH2iyQFkL1@VHimEf1~Ef?IyxC(9D&`!bpam za(O&vp{i%5bhnmO#ntW#6;iLJ-k`J~C84>uqir3v2pav6&rz7=QHz)*(FQx6qFGkM zsUp)k2ue+Mp|j|_S19&u43)M!r?d4{v{5=!lq-=;jP#$3mF zN0RQ8$I(}h17>Q6TyZM&Gb;1EW+IU~efKIhiM)Jh#;hs=3!&!Re38AerUvMyD76Nw zePWgJQ+Vgm^Ai;tZa{!!NK%I~L5ECU4p!~gl5`heAlg4?D@0g|pnF*!MrC)Wi=Jyu zby${+-j5~@>deGAGUUE>4mq&$J%zyE4HSN3)=^gG;ody6R+-KUdDOV~G;`~}>kRk$ zu*)Rns3B@oE%NTtyN6P3h=gIO*i{)VsVy#-2H)9$N-_=Y(Ip+aBy8FSoA_DawGUvr zxJ{jf(E2!1<`*D{RA1D!;S`<038z-*TBWHhx*Vq1N>oHg+!$iWZ`r>WezfDfQ$B{a zb0x%2XtxHvzZx0xsLdm6`xS=+t%0LP-D&={vtV#<3h0f$qinTqy2(JHnGeF{onl;V zSD4&AwhL>L=n<{`+c=hp7R@O`kNlM1V1g6xo`cpem6PuJGZ!sct0S*+<7zJou90MT z9xZkpa_<9QSKIyaVIXN36)-B0aA%_x3yQs*%SvBAMcmrA;&J4?ItA5(T9s6HSSO{2 zAYSTj<>fwR(ZsiUYAZR5?P^~acbItObkp^E-OS7F{lmv&-b+AmHaF#gq9aROKimmF z;@=+`b1Tl(4>{OtQiPFL0DFt2?iIuFV=WYzDgJv;h*kcMlVGkp-8TG}Q;WZ|-d#T5 ztu(M)rB|&aW!)?1yhr)*UOFzvz2)M$Oh+#$&3K-(eHUwHbPWAFl|-hH*BtaoN!}sz zMd=9#{;Y)Oi;@BwEo(wfb8cbRKLySw(%F#Dgzpf3-im~i0v^EgK#`jjIHcaOF5n5K zP*1b>FC4wgq%~!@Ni9=NT`3n3l3kP(M9VRp;zp^|$)st*Lc$B8CljG?k!e7PGw8aM zggXSlXyIj|;9d?`5+XDthojfH(aSp&(gy!XWsbFWCcGeCvY74Oa3QrB7E?xO5!KOA*dczwi4subb#d zL6#~*Qmv`l>K&`_Y#Fet$f7cT$*W5aO$;l>mnWn&5~yUoP^A;7$Y=zsF^frDMcmE| zt-pDJ1AdB90SzOkTc;r8DEX`SApQ$ zr{s<5DkQE+<`u6r8NarLuO z6Y3zI26|I5T@2AEKv)DBl1`=;`9%40&`*h6>KfZ=Qx|yrItz^AC;4jXVy_PRgJdff zigj#FE8zW{Wt};tbFf-V+6ZT}EUFo{1}%*IrFSEx2|?%Er9Az>(+^a<`oNqNtvgE* z(+G+uFCLFPIbwvGg`z^v)q?b$2KqEq81)L3p14ZoRun_)->dAFbGsQqKI) z_~4xDa*aDc1@;|#a9~`UJj8R10DS@dmjf%;Lo`0bG=k2AR*mp=(4>zZG>c7ewAI`uxgui3U{s-Bizm4AAkb=90a3CjB&hyqy8baM2AT;9E)}$H3Ans@MKAwQ$Qf4~V zr}A2(m}utI>}2xrXcozcXt32{=6URr^j&Uq3uS6=E0+ZZOcDm;-El!ksZ-+1KhJcd zlvve%#9==^CVF9?mqBw%|1CaK{VPUNNe)pk4=VjksrS@#aR=s#4qIiT$8tAMi};RD zN@(6Ogj!EPxn>`);{~&D7#qUi5%}_Qo$Bu|ZYb^S{s8TX5Zyf*kRY8G4P#rO zvAJ*w3-TLnb)2m-`TMw!#@9ffhPl%92iny($R}Iz?d(I(dG5gOJ9*XAuZ!5_^vpmdU$&VEKx&VEje2fPQ49h*^*^wSSK399c103?A5!GS%oAM4YQLz+@Ux` z1B)#qtku1RoGEeF#eRWyqOLNCi+~exhY5sO;M(a(&Sy;DFXL!`(z{`v+r5R8auG*y zQJtuZCSum!-F`dBwG*0pRBa3)_>!>OKZ3mmFRigKp5{r2XTGQX@J`2FU{(Fr z*B2%kgq!&ONk^_6L&J1fTEdgNyq0zubeZYGup7= zm@6wAk@UjN;2f9SU_)e`8egv?#NUz3dmgMIEJ{it1Hs)N_t7&Xb8CsVSVoh@^)Oab z?P3^K}->!Ii_v1%jWuyGnB~*8D21#zil$XG3oq5u5Egy^(;Ai8t3pwCs{F(gT z2#$&8`4=yFwA&*^+tY458wH6R9ALKzi!K&1{Y8!+K5#o6tvq!&dJOZ7K?Krs?uo(PICo=eX64wjBt(USs zO9N-Rkd0N<_)_=y^Iwz9@awCit9*GDBX?jqe%zcheVTL`e1Q$2-#4vt=)R172`;6n*~n!bF|c@3k*$~osuKt&ury1-y4 z>JF@42AqhhrS80|R=A-fsgjhxAh(#nk_oGW_XVUcRwagyTy~sVaRon?6PjkPKbM-1 zt-*8(RuLjDmQ0-Bc@-_dmVibv&d(<2|NO^93(5ZOM&0G~HPr#H zN|#e5U{Fvd_^T)rtr>7o-{tv1ulVq061#QqK4zqk&oF}d?lmzziPgCtimyq{;0_-y zWBoYP5=&aoKJDu%yB_d~A0H)nR84)=H;d>tfnF4D`r`2v`ZXlF_!{)$U^o>0l$n%I z-h=jg@j>I)55PGa-|OO+3YAb4cSn*Ty_q+7dA|^7%R*Wqp=3z-3d_ZdK`*r$Y`Saw z&fp9^3d+@}$a#PAEk6hSUpa#Yz4DUG429{*bF0`0?n`>-=h`65r@<6wK0D6EsBwH3 zf6n^f59jf|ms?Ql(qNoa9e`^GKn zCIsgrjyvg)Jt1lMBFf>4EX3LW-Ma?Mf|cc+@8qp3q3WabtGb=XDHZ>(eG=nFBii!G+*77n-_H<-P%mJjEjM_4?2kAn0jd6iM`rHA*Im@ctS354Fq=<_`4mN530|w_TSUN#9ePEhFYQH zaBG40K63V1zEHrY6_P8t1+q9pqai|;f=x7GV&28vrEL8QSFWfzI{KmmnF+jc@oup+ z&37Nni%p(ki-=HLKEjoqm-_TcNZr+q+(EHIgI?(?5_3LjtJ5{&IWFfpv%`F{v3-?b8 z+@t)nQS|{~O}+b)jvs^^Zt^y*gX&r%c2VEFXW9sbM(ynZFX_aDB7Y(lQi`vq#;01c zF_Z0sS?3>Sa+FFEcy_0HNfuqfw2Tel`^#y$ldp}Pyev65^nUS1-hve=d8E2i;ANpx zK2R{5+y{&J&LlSzqOmkq+*`Fn)i!0BFN$gGG0V=sO$wJW%lW|Zu^fA4j#AIfciT1; zH$k1b4-fu^3}<3Jtsv_u91gcg6ui|VaDVy{dekk@?*l0aHquaVD5=D%0}NayCWaq} zAWqU#P(AzODWvO@eG-YI7}H(BBJwN7{P@Qz2q>=3lk~TpK#UucI;9JO*G@k6&Q5*? zCQyB+w_B=eS2V#d0q5&Hu{7EeM*Vngd-m@J|R1z-uOD|r;T`&w{ zCO|UR2w&hGO{2w@#H<#0J1aiF)o4PT1tI3T@Q{S0!2?)I{PW13l0YGEW*r8Z+^i4W z+fIU`2erE!xT6mpu_ab#QpNRt#2`%S@o&Y=|2(QqxiVG7dy*2(yXt#;*l|^iL&0PP zA!CDqU^ARMh?~IMN=J!RM3mfUzehPDqAUNmeCCZ?#@CExA#uz~ta|_ET#s&I0x^CE zjtOb|ESQj{45{9lgoAktX`P~d7l8>AR=)>F2N3qovi5J?0})ZS;&CfktI0*or!E`^ zLXWs`{fwi~(}Nc;UW|gvSB zn$lwuCp9EvVPQc@MI}EmVc?pHM25Y7{hEWDJ2YHGL}cAo-PriIw16Y5L;Rhw??jW6 zlN_9!-SPgsBUI+H+c>W}9$YhiA<|5i=*5^jg+DVmIQSjLOHj_JF`{^cl~__DzAfNk zryWY z`0Je3ivTGcs6XTq(O`kMTO**z|I);vj{FY!9kBYiALMgLNDT2H6JTzyKV+EgAZ%<{ ziB8?T+1A_NFT<{qU??CWQ3^d$h|JE&cni9Ree@K4tWOdzu2EOh+?gyl51OFa0TzL> zLzO2MB^jBrynI?QUsH4Qo6Jn^LVXutiO9(#HKk=b-^1>8Nl6AQl~rmK&wu3fwK$>YdaZW>3^=ed9 z)bf;f=a5?8L0lZebrqG=EbYSLa3=~zDy2K$urKnQk7SRgjE5;SDl@+OK4?=IE^@~W zzuRQpDi!OF-22QFU*V;FU*zK53FJ^jpGZK*LJxb;^=4&N)GLY+#|rBPK=FEbuy?k% zyL%EgduGwkPq9dP>Ae{_4n-0vtpMxGFWI^)AKghAM4yF0rQ4TECC0@r@bX3nhbX;R zGJO1d#i-@k5ceRI1fdZ3zeh@3qe(=*nA&bG=)Gh)^F5^|Q zd()9BrrXOSabI=~H*Z=CoDp(bWGJy)e)=L_?8T$+oTlodX`c+xjWBubE4Y*HWXTM4 zbk#Ef1cfhN7>CjG^5y6YoFnswyn(z5bwRS?TJySEOG9$eTQ}24rT1fcmU_}iJrn{( zKYaL*0y?L30Sc|@NXe$%Vn51Oi2PVSWr-)ZfFSokdcuVOsEz6 z&2pr0UGd248nXv_NZQ;=_yT8i1974P(E}YtAN3ToO21~TgJj9~7SyR*bHDS`_X>jmtMb};3Q`@2LBoqi$drjwg=tPM7)~HLR4B*_Z zywA%E>ba?;lp0Na(!DEX#_8R%6v}ADB0&0clx@h`Ke6M$a{9!Sc}~PLn;$MO7@iHpvW08dQB#yf!X!kCtBDvnD8SsYm zok#o;a~qv_2z*e^w6D5gW8I`O=|CPoeQ8+iu)%MCpglm}{ejTa&@-=orISOyKZ=Zj z`oMBNz={gwge*^w-vE*Ho0;buuHy&rSwezx2sO_*7N8if7d?f4^1EutqvW`dCDJod zi@V$;Ou+7gclHgBHnO`bwb68c2PI_O#LUDme>?kpKd^xHu8-3#wODU@1=NX509}?veC#NZKClL=J^^oEApnH%yL^z?ei0l zomHJmZ=NfIv3wrSen^v8AqwZm^wHIGLQS=BpghYxl zH#14thdu`zM>yWzy}*pXxJD^||B~p}W2FvIqSASMKpAU_l*wJI{`63NzOd3Q2&@fG zr}1&_y*L;xN54->@?>(JmnAdzyBCzEFJUu%^)Op;?3SKKbRw1Nw!lL!K0b`4AH_0h zCsNXlc?}F*9D8UIw8lYTFMM}>4&@+}Km$P0XP_G~oMi1a#BFmcfI)=h&em4_R+ z&Wn6wDgLuzGlRM#@&w!G5;_=5I+#mv{w($}r{fH1n0+aQev;DrQVvCyAJO2*LQ2Aw zm{F&&Q?sRYQqCN$kl*TD@V`FBMSz8?M57Wf9(Czbw-`C4nl7S&wq@z=QlE0JU+42g zJ(-!A86oq2Ri+|e{Sd6%O1bqioH%#{uMxA9lQ^uZEUL_dxLtXH$a-c@9PKOLs{OCt zk^s#Fs9y^{phn*r-_3km;K$bfC1>nvmw*66_3U?Xap}?t_t&wpJ81{QHo*gQJ{QyU zcsDy3YFgs9I_Pg;f3$sHo;5W*`$gey`Hb$~dTgZ~bRCDxTkw9Xe)XB{$k^BxCBEhU zyvUYQUmaJ+Sze1jxx@(5X@XOSvE8Mcv0w~9T()1Ny*KN$y(}eUJ4?(Is4#u0j)8k7 z&{Pr(6cH}B7tHL-jb%Pn6&7Z=zPuEpq$8wo4!D=vWh_I*Y+fwDx;@iRPf#xrE{!JY zOSsh-?oHkdSF^j#;FNujp8mQW@_pE&4`v*alHP1qaep?;I&2qw|wDk6c9?}#~joURnAZm`;#y> zyWyrhL2oW70)_yg+c%}{AbqZl>yo%bVz&1aV}+>U!1XnGd+f+ls|&PqT$l&E{JF!~ z1eXe#-Q9p)F&r*0gp!{T`CwXNN%UpC{OMbOEdFxWMUef$DHZ>kD}lT#Y;&3|i0z%U z7Zf6%a5?x$V{Gl~hf5Wkw|i9O6r01k!zfBkd%D_gJI-Fvm=ASrSKE&?gs6n&O5{bJd`4-k9^ zl^M^2N9_H|G5iT^S}CcijswP5GW8!*MSM)~=1H;7Grkmk8K-}RT8T+6SN;u89Uj4D zh#1*5|Fbkmy<2+KLZN712tnZ-4!u&Yd}7xiUIa4A!)U-@ z1NYneJMkCnJZHar+?bCnRJG78K*z8rz$v3IHRRXxRW9EBvJ=GaE(6%&(i?zu)%mdl zrWh5aF{}q$YZ95KsdRh|x;v;nS_7sbV&KaW&51x6R2Oj?8o^NjG_c3VhohUi2eLv&r1G+Y_RCJ?t6Bg;Fr z#!Gujv(7?cap>^vQM4xr02V|96#c@misyUv#!sC6O^K{h)!(Lp$rQ?3!@7fia8+Ix zw>)+mOR91bchJp-6n!^R<_6iHZTvCioK+?UVxhlSbus%F9R%|rkrJ~}q6k5DU~p}B zE#xj47SSS(HuuOGGIH!lQT(nqh-KR^nRM~swOHb0GBrmCBg-_iMWEQ1=S>h3X6lXv zsYu}D(fn}0tK1&+ha|7KpTSqXe#cf@FzU;TTN=7lDif+2bv#))0}rxHnI< ze1j)S+p4_Q9a0Q((QhDeTb@#Vb>T)#71{&d3Kz0gQxi~RyxYPma`|$IK9>&vJOQ%5 zu|(nt^e_;iD2?S2$N@Po@Tv}ku1E96B}?Bu-g+JQN6wW6@A*G!mdN$mS{!R`Ir}k* zoE(1f^Q%vG%RDgLAzidcXM&veup90bwSBj5`wJJ@jc^oaF0ov3Srh48_t1 zn#I-++h6@U4|u7w=zyySz}LA`utQP_@Kc{`TfYEwy^$Jko(s82nR&#Qw$OEJc`YwJ zt^n@L`Jh}X=kp0m)4&EknSwpc))#hx&3vS!JW@Y4sxN#9bkxj1c-F5q_A6tx_^^48 zlxBS+K+c~_s9c~cajv7H^g7=mOQ2?;!ICxlG5WdTbJI!BGrjB=ylUiXb-VUhvMY?- zs_~qcd&WAYkrfllEpMiP+pocRo7&FNc#KtLAbbATAYcH5GYwhNP017D6yLFM@L5|WLQUVPk}C??9QE6 zt|T}*+@Hs+?rXWT(&J*@nl7!s&c2as0;Z`yu1e`~&HIy34{sHXc56g-$&= zZ%VV#^d|4-9*ak>=1Y4>SPT?+lTgm8xle#()-tJgn)t5docP^K+xM^EY)%^7;~aF- zXLQToIxDW>Iz1d^r=XY%dV3JP#7gnVHD-(Eug?!fz^EhqM8ixBSlrpJ6BttMM_gci z&e9JbZ#Zar-c;k~9j5Oxuj*d-9k!ph!54kSUm8q-EMnR2ME%&9XYh{~c#?Mov)}c; z;igvO%usqb)!s25AATsq9!xk=K|O}v9_yUWTQ4C%YV?_G^g-{*U49ZZCn*slWPf4A zzOAw+*J}#V&m(U8Vsv+!s(Gj~Xi!paU8TDKHDYXFKdrcheIxJgR3H12Kw+&)&rTDRK#W+LPZ8t= zMoZaD6DZE!J(4q*`NSK&B>y5LjWk-<-_%Elg<=q zStI$Gk+f5z!%aDvft7ru~j zh&4*28LuVA@YRDI{Z860U&#CIC5fsTPd5(yv&|C+Qr0W|;iSUEGU2oWancn1{7uBn zvkcH-?40TaR z+=1}yInmcge3JH_$d!AFjUhS~jpuIYAc?=6Z~>Z&R-zjpetGeB+2n-^6-~F*;lA9U zOVef9`Y=@L{m#&!5DrOJ@2y#@Af2J9C4j;?;-VL&~&xsy@bV_C(nShlZx??7)`UK#`k?p1%E-o7++`5z=gm(fA!{Qc&vb zt!k8;UHW78-{=b4m|mJj#>IUX^;U-9xIj!S-8wv&Uc5}4Kw5%s8Ey@GSP>Hu8_jNf z-4;DMEtzuTx*H$geiHU5Po0VI*^TcKx^%(#7l`rK)yX;ZGq;MXQ^ElN)A*T55`Y?K zOcl8~%F!Qni=a)dD`I0ug(+`=cS;B% z-E2xa6r@u?O1i^MgMhS1DGf@)rn^CD=?>}c-2cMoea;)_oacPsH_jOE_#J~G1Mj`r zu6y0KpHl$z$#^7)%!4Rab@tAH1dl}NMVzVN$Ixa~7FqZ1IJvd_q@sDoDKAT< zfzb!r7E`G|>yjB$R|HWMwo^}aZvT7rDKMCG{x|>mdn5h0&d`EJ;d?twOctwjEvV49 z3&TUBQa@KyIksdJdOw#sTq9gyZLVHw_#~xP?VxyG&7|xOJ2f^;Twg7O!(FHn$j%N_ zQi^_q&t&Qr01o{VJGlj<$pCJWAXhDIpYS}*wgXtm>)*ZSv?7E@D z^45u_D;>8fL%5zaEcZQ-L+i#tH7PE_o*$7^2+tq1@ukc5i+I^vWxD{~KKEPvqzpy#5=C-2fnO zB_$od+f_cRMfnYT>pTB-yU*glk+}zKlrG;2+?~H(w=?JLE4(#1i*$z>LbWsi<=MKI_3{C$TId{JgyRpa_A9oFnh_Iu;K@=-4 zJ}It1mfo%U_0@5Gn#5wFr%~BrTczekE@8iEP2UHup>?^=?;TO*J@WhHAe*=a+Zon& z45uD$SxhOi&m1;7&P{uq$z1SUsJZF#*^Uj6LI3IiA25b)>$=QHfjbeCX@NxyaF_27!-|<~QQSOVLcw|HyT$xrc)0sq{c9e7vvI z_T~rKQO8MT4NKTJ>c2eAsmCV=Qcx`M{wd^`en!&9@ON~10>Sq0C$xNvCsH#qqFzRc zN0=n4Za)&Xs{A zoZlpa`;uo$fQ z_~{cfK#x8P6khw38+81{#~~N%Z@AE%Y%pV;>Qp3_gCBHB2=ZPWMW%hZx;~#FWprX2 zPZw!XQd7eNx3knhM@?-BhAv)=O-3fEqN1X%t=(xoLrqT~NKHeNm!IF`^&Ni($0{Q| zgYmII81rN1ywbWMuW265&=f{KzPQV{RBDRfxBZk)cJ!+yo1 zwCuXcZaWxc>ys&@Jjo#JFNKMj@vR|hWST$@sS_YxGq0@y7MG{r$inZGAAE7NE@5c+ zrQCMTyHXb$id2qJ9)y5`X)o|TBqaN(S¦X|nB#q8}Vmq$#Y08)7#tn|5R+!fr5 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 zSTȻ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�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 literal 0 HcmV?d00001 diff --git a/artifacts/firefox-nodes2-mask-controls.png b/artifacts/firefox-nodes2-mask-controls.png new file mode 100644 index 0000000000000000000000000000000000000000..8c187f07047215647b4fac6c35e33c7da341c697 GIT binary patch literal 114092 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