From e513baf70a20306856e40fbf2afd80b25f5655a6 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Thu, 21 May 2026 18:41:18 -0400 Subject: [PATCH] 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 + }); +}