From f9223bf051d577efba6a9411b9330e9436033afb Mon Sep 17 00:00:00 2001 From: pbanddev <202373785+PBandDev@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:24:54 -0500 Subject: [PATCH] Initial commit --- .agents/skills/comfyui-nodes-dev/SKILL.md | 49 + .../assets/v3_extension_template.py | 41 + .../references/folder-paths-and-files.md | 54 + .../references/image-mask-performance.md | 57 + .../comfyui-nodes-dev/references/io-types.md | 56 + .../references/lazy-evaluation.md | 61 + .../comfyui-nodes-dev/references/scaffold.md | 26 + .../references/schema-nodeoutput.md | 67 + .../references/ui-helpers.md | 50 + .../comfyui-nodes-dev/references/v3-core.md | 59 + .../references/validation-and-caching.md | 36 + .gitattributes | 3 + .github/workflows/ci.yaml | 52 + .github/workflows/publish_action.yaml | 169 +++ .gitignore | 21 + .python-version | 1 + LICENSE | 661 +++++++++ README.md | 68 + __init__.py | 24 + assets/icon.svg | 58 + comfyui_ping/__init__.py | 11 + comfyui_ping/extension.py | 29 + comfyui_ping/global_completion.py | 126 ++ comfyui_ping/nodes.py | 47 + comfyui_ping/notifications.py | 78 ++ comfyui_ping/routes.py | 248 ++++ comfyui_ping/runtime.py | 87 ++ comfyui_ping/sounds.py | 84 ++ package.json | 43 + pnpm-lock.yaml | 1222 +++++++++++++++++ pnpm-workspace.yaml | 2 + pyproject.toml | 53 + skills-lock.json | 10 + sounds/bundled/beep-ping.wav | Bin 0 -> 22128 bytes sounds/bundled/harmonic-beep.wav | Bin 0 -> 9050 bytes sounds/bundled/notification-soft.wav | Bin 0 -> 18600 bytes sounds/bundled/ping-failure.wav | Bin 0 -> 12392 bytes sounds/bundled/ping-ringtone.wav | Bin 0 -> 21212 bytes sounds/bundled/ping-success.wav | Bin 0 -> 9304 bytes src/api.ts | 171 +++ src/audio.ts | 162 +++ src/constants.ts | 10 + src/debug.ts | 32 + src/index.ts | 660 +++++++++ src/resolve-sound.ts | 32 + tests/api.test.ts | 101 ++ tests/audio.test.ts | 236 ++++ tests/example.test.ts | 22 + tests/python/fixtures/example_node.py | 26 + tests/python/test_bundled_sounds.py | 42 + tests/python/test_entrypoint_smoke.py | 50 + tests/python/test_example_node_pattern.py | 28 + tests/python/test_global_completion.py | 177 +++ tests/python/test_notification_policy.py | 67 + tests/python/test_ping_entrypoint.py | 210 +++ tests/python/test_ping_node.py | 134 ++ tests/python/test_sound_routes.py | 353 +++++ tests/python/test_sound_storage.py | 55 + tests/settings.test.ts | 482 +++++++ tests/sound-resolution.test.ts | 34 + tsconfig.json | 25 + uv.lock | 433 ++++++ vite.config.ts | 33 + vitest.config.ts | 19 + 64 files changed, 7247 insertions(+) create mode 100644 .agents/skills/comfyui-nodes-dev/SKILL.md create mode 100644 .agents/skills/comfyui-nodes-dev/assets/v3_extension_template.py create mode 100644 .agents/skills/comfyui-nodes-dev/references/folder-paths-and-files.md create mode 100644 .agents/skills/comfyui-nodes-dev/references/image-mask-performance.md create mode 100644 .agents/skills/comfyui-nodes-dev/references/io-types.md create mode 100644 .agents/skills/comfyui-nodes-dev/references/lazy-evaluation.md create mode 100644 .agents/skills/comfyui-nodes-dev/references/scaffold.md create mode 100644 .agents/skills/comfyui-nodes-dev/references/schema-nodeoutput.md create mode 100644 .agents/skills/comfyui-nodes-dev/references/ui-helpers.md create mode 100644 .agents/skills/comfyui-nodes-dev/references/v3-core.md create mode 100644 .agents/skills/comfyui-nodes-dev/references/validation-and-caching.md create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/publish_action.yaml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 assets/icon.svg create mode 100644 comfyui_ping/__init__.py create mode 100644 comfyui_ping/extension.py create mode 100644 comfyui_ping/global_completion.py create mode 100644 comfyui_ping/nodes.py create mode 100644 comfyui_ping/notifications.py create mode 100644 comfyui_ping/routes.py create mode 100644 comfyui_ping/runtime.py create mode 100644 comfyui_ping/sounds.py create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 pyproject.toml create mode 100644 skills-lock.json create mode 100644 sounds/bundled/beep-ping.wav create mode 100644 sounds/bundled/harmonic-beep.wav create mode 100644 sounds/bundled/notification-soft.wav create mode 100644 sounds/bundled/ping-failure.wav create mode 100644 sounds/bundled/ping-ringtone.wav create mode 100644 sounds/bundled/ping-success.wav create mode 100644 src/api.ts create mode 100644 src/audio.ts create mode 100644 src/constants.ts create mode 100644 src/debug.ts create mode 100644 src/index.ts create mode 100644 src/resolve-sound.ts create mode 100644 tests/api.test.ts create mode 100644 tests/audio.test.ts create mode 100644 tests/example.test.ts create mode 100644 tests/python/fixtures/example_node.py create mode 100644 tests/python/test_bundled_sounds.py create mode 100644 tests/python/test_entrypoint_smoke.py create mode 100644 tests/python/test_example_node_pattern.py create mode 100644 tests/python/test_global_completion.py create mode 100644 tests/python/test_notification_policy.py create mode 100644 tests/python/test_ping_entrypoint.py create mode 100644 tests/python/test_ping_node.py create mode 100644 tests/python/test_sound_routes.py create mode 100644 tests/python/test_sound_storage.py create mode 100644 tests/settings.test.ts create mode 100644 tests/sound-resolution.test.ts create mode 100644 tsconfig.json create mode 100644 uv.lock create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.agents/skills/comfyui-nodes-dev/SKILL.md b/.agents/skills/comfyui-nodes-dev/SKILL.md new file mode 100644 index 0000000..df48ccc --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/SKILL.md @@ -0,0 +1,49 @@ +--- +name: comfyui-nodes-dev +description: Develop ComfyUI server-side V3 custom nodes (Python) using Comfy API v0_0_2/latest, schema-based inputs/outputs, ComfyExtension entrypoints, and UI preview helpers. Use when asked to create or update ComfyUI custom_nodes, V3 nodes, or Comfy API-based nodepacks. +--- + +# ComfyUI V3 Custom Nodes (Server-Side) + +ComfyUI baseline: v0.12.0 (Comfy API v0_0_2). Update this line when revising the skill. + +## Scope + +- Focus on server-side V3 nodes (Python). +- Exclude client-server extension patterns and frontend-only extensions. + +## Quick Workflow + +1. Choose API adapter: `comfy_api.v0_0_2` for stability or `comfy_api.latest` for newest features. +2. (Optional) Scaffold a nodepack using `comfy node scaffold`. +3. Implement a V3 node with `io.ComfyNode.define_schema()` and `execute()`. +4. Return `io.NodeOutput` (or tuple/dict) from `execute()`. +5. Register nodes via `ComfyExtension.get_node_list()` and `comfy_entrypoint()`. +6. Restart ComfyUI and validate in UI/API. + +## Do / Don’t + +- Do use classmethods `define_schema()` and `execute()`. +- Do return `io.NodeOutput` (or tuple/dict) from `execute()`. +- Do use `ui.Preview*` helpers for UI previews. +- Don’t store mutable instance state in nodes. +- Don’t use client-server extension patterns (out of scope for this skill). + +## Reference Map + +Use these references as needed (progressive disclosure): + +- `references/v3-core.md`: API versions, ComfyExtension, progress reporting. +- `references/io-types.md`: IO types, widget inputs, custom types. +- `references/schema-nodeoutput.md`: Schema fields, flags, hidden inputs, NodeOutput patterns. +- `references/ui-helpers.md`: Preview and save helpers for UI outputs. +- `references/scaffold.md`: Scaffold workflow and V3 defaults. +- `references/lazy-evaluation.md`: Lazy inputs, check_lazy_status, execution blocking. +- `references/validation-and-caching.md`: validate_inputs and fingerprint_inputs patterns. +- `references/folder-paths-and-files.md`: folder_paths helpers and save/load patterns. +- `references/image-mask-performance.md`: Tensor shapes, resizing, batching, device tips. +- Official docs: [Comfy-Org/docs custom-nodes](https://github.com/Comfy-Org/docs/tree/main/custom-nodes) + +## Template + +- `assets/v3_extension_template.py`: Minimal V3 node + extension + entrypoint. diff --git a/.agents/skills/comfyui-nodes-dev/assets/v3_extension_template.py b/.agents/skills/comfyui-nodes-dev/assets/v3_extension_template.py new file mode 100644 index 0000000..45f834c --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/assets/v3_extension_template.py @@ -0,0 +1,41 @@ +""" +ComfyUI V3 custom node template. +""" + +# Prefer v0_0_2 for stability. Switch to comfy_api.latest if you need newest APIs. +from comfy_api.v0_0_2 import io, ui, ComfyExtension + + +class PassThroughImage(io.ComfyNode): + """Return the input image unchanged.""" + + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="PassThroughImage", + display_name="Pass Through Image", + category="Custom/Examples", + description="Return the input image unchanged.", + inputs=[ + io.Image.Input("image"), + ], + outputs=[ + io.Image.Output("image"), + ], + ) + + @classmethod + def execute(cls, image: io.Image.Type) -> io.NodeOutput: + return io.NodeOutput( + image, + ui=ui.PreviewImage(image, cls=cls), + ) + + +class ExampleExtension(ComfyExtension): + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [PassThroughImage] + + +def comfy_entrypoint(): + return ExampleExtension() diff --git a/.agents/skills/comfyui-nodes-dev/references/folder-paths-and-files.md b/.agents/skills/comfyui-nodes-dev/references/folder-paths-and-files.md new file mode 100644 index 0000000..38a1940 --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/references/folder-paths-and-files.md @@ -0,0 +1,54 @@ +# folder_paths and File IO Patterns (V3) + +## Common Helpers + +- `folder_paths.get_output_directory()` +- `folder_paths.get_input_directory()` +- `folder_paths.get_save_image_path(filename_prefix, output_dir, width=None, height=None)` +- `folder_paths.get_annotated_filepath(file)` +- `folder_paths.exists_annotated_filepath(file)` +- `folder_paths.filter_files_content_types(files, ["image" | "video" | "audio"])` + +## Save Node Pattern + +- Use `get_save_image_path(...)` to resolve output folder, filename, counter, subfolder, and cleaned `filename_prefix`. +- Create the full filename with the counter suffix: `f"{filename}_{counter:05}_.ext"`. +- Return `ui.SavedResult(file, subfolder, io.FolderType.output)` inside a preview helper. + +Example (abbreviated): +```python +full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( + filename_prefix, folder_paths.get_output_directory(), width, height +) +file = f"{filename}_{counter:05}_.webm" +return io.NodeOutput(ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)])) +``` + +## Metadata on Save + +- For output nodes, include `hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo]` in the schema. +- Use those fields to embed metadata when saving (e.g., video container metadata or SVG metadata). + +Example (abbreviated): +```python +if cls.hidden.prompt is not None: + container.metadata["prompt"] = json.dumps(cls.hidden.prompt) +``` + +## Load Node Pattern + +- Populate file lists from `input` directory and filter by content type. +- Use `upload=io.UploadType.video` (or image/audio) on combo inputs for file uploads. +- Validate file existence in `validate_inputs`. + +Example (abbreviated): +```python +input_dir = folder_paths.get_input_directory() +files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] +files = folder_paths.filter_files_content_types(files, ["video"]) +``` + +## Validation and Fingerprints + +- `validate_inputs` returns `True` or a user-facing error string. +- `fingerprint_inputs` can return `os.path.getmtime(path)` to avoid hashing large files. diff --git a/.agents/skills/comfyui-nodes-dev/references/image-mask-performance.md b/.agents/skills/comfyui-nodes-dev/references/image-mask-performance.md new file mode 100644 index 0000000..df84c40 --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/references/image-mask-performance.md @@ -0,0 +1,57 @@ +# Image/Mask Performance Notes (V3) + +## Shapes and Conversions + +- Image: `torch.Tensor` with shape `[B, H, W, C]`. +- Mask: `torch.Tensor` with shape `[B, H, W]`. +- Latent: `dict` with `samples` shape `[B, C, H, W]`. +- Convert to channel-first for many ops: `image.movedim(-1, 1)`. +- Convert back to channel-last: `.movedim(1, -1)`. + +## Resizing and Batch Alignment + +- Use `comfy.utils.common_upscale(tensor, width, height, method, crop)`. +- Use `comfy.utils.repeat_to_batch_size(tensor, target_batch)` to align batches. +- Use `node_helpers.image_alpha_fix(image1, image2)` before compositing to align channels. + +## Device Management + +- Use `comfy.model_management.get_torch_device()` for compute. +- Move outputs to `comfy.model_management.intermediate_device()` when appropriate. + +Example (abbreviated): +```python +image = image.to(comfy.model_management.get_torch_device()) +# compute +return io.NodeOutput(result.to(comfy.model_management.intermediate_device())) +``` + +## Mask and Image Compositing + +- For masks, reshape and interpolate to match source size. +- For image compositing, use channel-first for math, then move back. + +Example (abbreviated): +```python +mask = torch.nn.functional.interpolate(mask.reshape((-1, 1, h, w)), size=(source_h, source_w), mode="bilinear") +``` + +## Batching Utilities + +Patterns from `nodes_post_processing.py`: + +- Pad channels to match before batching images. +- Resize all inputs to the first input size. +- `torch.cat` along batch dimension. + +Example (abbreviated): +```python +resized = comfy.utils.common_upscale(img.movedim(-1,1), target_w, target_h, "bilinear", "center").movedim(1,-1) +batched = torch.cat(resized_images, dim=0) +``` + +## Performance Tips + +- Prefer tensor ops over Python loops when possible. +- When loops are required (e.g., per-image quantize), keep tensors on CPU and minimize conversions. +- Avoid repeated resizing by computing target size once and reusing it for all inputs. diff --git a/.agents/skills/comfyui-nodes-dev/references/io-types.md b/.agents/skills/comfyui-nodes-dev/references/io-types.md new file mode 100644 index 0000000..25d41c6 --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/references/io-types.md @@ -0,0 +1,56 @@ +# IO Types (V3) + +This reference summarizes the V3 IO classes exposed in `comfy_api.latest._io` (and re-exported by `v0_0_2`). + +## Core Scalar Types + +- `io.Boolean` +- `io.Int` +- `io.Float` +- `io.String` +- `io.Combo` (dropdown) +- `io.MultiCombo` (multiselect dropdown) + +Each scalar has an `Input` class for widgets. Example: + +- `io.Int.Input(id, min=0, max=100, step=1, display_mode=NumberDisplay.slider)` +- `io.String.Input(id, multiline=True, placeholder="...")` + +## Common Comfy Types + +- `io.Image`, `io.Mask`, `io.Latent`, `io.Conditioning` +- `io.Model`, `io.Vae`, `io.Clip`, `io.ControlNet` +- `io.Audio`, `io.Video`, `io.SVG` +- Additional specialized types exist (see `_io.py` for full list). + +## Advanced Type Patterns + +- `io.MatchType` for polymorphic inputs/outputs with templates. +- `io.DynamicCombo` for branch-specific input groups. +- `_io.Autogrow` for expandable lists of inputs. +- `io.MultiType` for bounded unions (e.g., image or mask). +- `io.LatentOperation` for passing latent ops as callables. +- `io.AnyType` for opaque passthrough outputs. + +## Custom Types + +Use one of these patterns: + +- Decorator: + - `@io.comfytype(io_type="MY_TYPE")` +- Factory: + - `io.Custom("MY_TYPE")` + +Custom types can define nested `Input` and `Output` classes. + +## Inputs and Widgets + +- `io.Input` is the base input class (no widget). +- `io.WidgetInput` is the base for widget inputs. + +Notable widget options: + +- `remote=io.RemoteOptions(route, refresh_button=True, timeout=..., max_retries=..., refresh=...)` +- `upload=io.UploadType.image | audio | video | model` +- `image_folder=io.FolderType.input | output | temp` +- `socketless`, `force_input`, `advanced` diff --git a/.agents/skills/comfyui-nodes-dev/references/lazy-evaluation.md b/.agents/skills/comfyui-nodes-dev/references/lazy-evaluation.md new file mode 100644 index 0000000..6017b2d --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/references/lazy-evaluation.md @@ -0,0 +1,61 @@ +# Lazy Evaluation (V3) + +Use `define_schema` inputs with `lazy=True` and a classmethod `check_lazy_status` pattern. + +## V3 Pattern + +- Mark optional or expensive inputs as lazy: `io.MatchType.Input(..., lazy=True)`. +- Implement `check_lazy_status` as a **classmethod** in V3 nodes. +- For optional inputs, use a sentinel to distinguish "missing" from "connected but unevaluated". + +Example (Switch-like pattern): +```python +MISSING = object() + +@classmethod +def define_schema(cls): + template = io.MatchType.Template("switch") + return io.Schema( + node_id="ComfySwitchNode", + inputs=[ + io.Boolean.Input("switch"), + io.MatchType.Input("on_false", template=template, lazy=True, optional=True), + io.MatchType.Input("on_true", template=template, lazy=True, optional=True), + ], + outputs=[io.MatchType.Output(template=template)], + ) + +@classmethod +def check_lazy_status(cls, switch, on_false=MISSING, on_true=MISSING): + if on_false is MISSING: + return ["on_true"] + if on_true is MISSING: + return ["on_false"] + if switch and on_true is None: + return ["on_true"] + if not switch and on_false is None: + return ["on_false"] + +@classmethod +def execute(cls, switch, on_true=MISSING, on_false=MISSING): + if on_true is MISSING: + return io.NodeOutput(on_false) + if on_false is MISSING: + return io.NodeOutput(on_true) + return io.NodeOutput(on_true if switch else on_false) +``` + +## Execution Blocking + +If you must block downstream execution, return `block_execution` in `NodeOutput` or return an `ExecutionBlocker`. + +Example: +```python +return io.NodeOutput(block_execution="No VAE in checkpoint") +``` + +## Practical Rules + +- Use lazy inputs whenever a branch is unused or a mask/ratio short-circuits computation. +- Prefer lazy evaluation over execution blocking when possible. +- Treat `None` as "connected but unevaluated". Use a sentinel to detect missing inputs. diff --git a/.agents/skills/comfyui-nodes-dev/references/scaffold.md b/.agents/skills/comfyui-nodes-dev/references/scaffold.md new file mode 100644 index 0000000..f43ead9 --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/references/scaffold.md @@ -0,0 +1,26 @@ +# Scaffold Workflow + +## Scaffold Command + +Use the CLI to scaffold a nodepack: + +``` +comfy node scaffold +``` + +This creates a folder under `custom_nodes/`. + +## V3 Defaults + +Default to V3 patterns when scaffolding: + +- Use `ComfyExtension` + `comfy_entrypoint()` for registration. +- Implement `io.ComfyNode.define_schema()` and `execute()`. +- Use `io.Schema`, `io.NodeOutput`, and `ui.Preview*` helpers. + +Auto check and migrate legacy scaffold to V3 patterns after scaffolding: + +- Replace class mappings with `ComfyExtension.get_node_list()`. +- Move UI outputs into `NodeOutput(ui=...)`. +- Swap `INPUT_TYPES` for `define_schema()` and typed inputs. + diff --git a/.agents/skills/comfyui-nodes-dev/references/schema-nodeoutput.md b/.agents/skills/comfyui-nodes-dev/references/schema-nodeoutput.md new file mode 100644 index 0000000..fdd270d --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/references/schema-nodeoutput.md @@ -0,0 +1,67 @@ +# Schema and NodeOutput + +## Schema (V3) + +`io.Schema` describes a V3 node. Common fields: + +- `node_id`, `display_name`, `category`, `description` +- `inputs`: list of `io.*.Input` instances +- `outputs`: list of `io.*.Output` instances + +Common schema patterns: + +- Use `search_aliases` to improve discoverability. +- Use `hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo]` for output nodes that save files or need metadata. +- Use `is_output_node=True` for nodes that write files or should appear as outputs. +- Use `optional=True` on inputs that may be omitted (e.g., optional second image, audio, or source). +- Use `tooltip=` on inputs to document important behavior (e.g., filename prefix, codec, indices). + +Common flags: + +- `is_output_node` +- `is_api_node` +- `is_deprecated` +- `is_experimental` +- `is_dev_only` +- `is_input_list` +- `not_idempotent` +- `accept_all_inputs` +- `enable_expand` + +Optional metadata: + +- `search_aliases` +- `price_badge` + +Hidden fields for output nodes: + +- `hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo]` to access workflow metadata. +- `hidden=[io.Hidden.unique_id]` when you need to send progress text to a specific node. + +### Schema.finalize() + +- Adds hidden fields required for API/output nodes. +- Assigns default output IDs if missing. + +### Schema.validate() + +- Ensures unique input/output IDs. +- Validates all inputs/outputs (and optional price badge). + +## NodeOutput + +`io.NodeOutput` is the standardized return type for V3 execution. + +Common patterns: + +- `return io.NodeOutput(result1, result2)` +- `return (result1, result2)` +- `return {"result": (result1, result2), "ui": ui_output}` + +Supported fields: + +- `result`: the output tuple +- `ui`: UI output (e.g., `ui.PreviewImage(...)`) +- `expand`: expansion dict for expandable nodes +- `block_execution`: block with a message +- `ExecutionBlocker` can be returned to block execution; it is normalized into `block_execution`. diff --git a/.agents/skills/comfyui-nodes-dev/references/ui-helpers.md b/.agents/skills/comfyui-nodes-dev/references/ui-helpers.md new file mode 100644 index 0000000..d214811 --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/references/ui-helpers.md @@ -0,0 +1,50 @@ +# UI Helpers (V3) + +These helpers live under `comfy_api.latest._ui` and are re-exported by `ui`. + +## Preview Outputs + +- `ui.PreviewImage(image, animated=False, cls=None)` +- `ui.PreviewMask(mask, animated=False, cls=None)` +- `ui.PreviewAudio(audio_dict, cls=None)` +- `ui.PreviewVideo(values)` +- `ui.PreviewText(text)` + +These return `_UIOutput` objects suitable for `NodeOutput(ui=...)`. + +Common wrapper for saved files: + +- `ui.SavedResult(filename, subfolder, io.FolderType.output | input | temp)` + +Example (video preview with saved result): +```python +return io.NodeOutput( + ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]) +) +``` + +## Save Helpers + +- `ui.ImageSaveHelper.save_images(...)` +- `ui.ImageSaveHelper.get_save_images_ui(...)` +- `ui.ImageSaveHelper.save_animated_png(...)` +- `ui.ImageSaveHelper.get_save_animated_png_ui(...)` +- `ui.ImageSaveHelper.save_animated_webp(...)` +- `ui.ImageSaveHelper.get_save_animated_webp_ui(...)` +- `ui.AudioSaveHelper.save_audio(...)` +- `ui.AudioSaveHelper.get_save_audio_ui(...)` + +These helpers handle saving and metadata, and return `SavedImages` or `SavedAudios` UI wrappers. + +Example (animated PNG/WebP UI): +```python +return io.NodeOutput( + ui=ui.ImageSaveHelper.get_save_animated_png_ui( + images=images, + filename_prefix=filename_prefix, + cls=cls, + fps=fps, + compress_level=compress_level, + ) +) +``` diff --git a/.agents/skills/comfyui-nodes-dev/references/v3-core.md b/.agents/skills/comfyui-nodes-dev/references/v3-core.md new file mode 100644 index 0000000..6aef5dd --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/references/v3-core.md @@ -0,0 +1,59 @@ +# V3 Core API + +## API Versions + +- `comfy_api.latest` provides the newest API surface. +- `comfy_api.v0_0_2` is an adapter that re-exports `latest` and provides a stable import path. +- `comfy_api.v0_0_1` exists for older compatibility. + +Recommended default for custom nodes: `comfy_api.v0_0_2`. + +## Key Imports + +Typical imports for nodepacks: + +- `from comfy_api.v0_0_2 import io, ui, ComfyExtension` +- `from comfy_api.v0_0_2 import ComfyAPI, ComfyAPISync, Input, InputImpl, Types` + +`ComfyExtension` is the extension entrypoint class. `io` and `ui` expose V3 node and UI helpers. + +## ComfyExtension Lifecycle + +- Implement `ComfyExtension.get_node_list()` (async) and return `list[type[io.ComfyNode]]`. +- Use `comfy_entrypoint()` to return an instance of your extension. + +## Progress Reporting + +`ComfyAPI.execution.set_progress(...)` updates the UI progress bar during execution. + +Signature (latest): + +- `set_progress(value: float, max_value: float, node_id: str | None = None, preview_image: Image | ImageInput | None = None, ignore_size_limit: bool = False)` + +If `node_id` is omitted, ComfyUI will try to infer it from the executing context. + +## PromptServer Text Updates + +Use `PromptServer.instance.send_progress_text(text, node_id)` to display transient text on a node (e.g., image size readouts). + +## Node Classmethods + +Required: + +- `define_schema(...)`: return `io.Schema`. +- `execute(...)`: perform the node action (can be `async`). + +Optional: + +- `validate_inputs(...)`: validate inputs and return `True` or a string error. +- `fingerprint_inputs(...)`: return a cheap fingerprint for caching. +- `check_lazy_status(...)`: return a list of lazy input names to evaluate. + +References: + +- `references/validation-and-caching.md` for `validate_inputs` and `fingerprint_inputs`. +- `references/lazy-evaluation.md` for `check_lazy_status`. + +## Schema Flags With Behavior Impact + +- `accept_all_inputs=True` allows frontend-defined widgets to skip strict validation. diff --git a/.agents/skills/comfyui-nodes-dev/references/validation-and-caching.md b/.agents/skills/comfyui-nodes-dev/references/validation-and-caching.md new file mode 100644 index 0000000..5ebd202 --- /dev/null +++ b/.agents/skills/comfyui-nodes-dev/references/validation-and-caching.md @@ -0,0 +1,36 @@ +# Validation and Caching (V3) + +V3 nodes can optionally validate inputs and provide cache fingerprints to skip re-execution. +These are classmethods and should accept the same named inputs as `execute`. + +## validate_inputs + +- Return `True` to accept inputs. +- Return a string to surface a user-facing error message. + +Example (abbreviated): +```python +@classmethod +def validate_inputs(cls, file): + if not folder_paths.exists_annotated_filepath(file): + return f"Invalid file: {file}" + return True +``` + +## fingerprint_inputs + +- Return a cheap, stable fingerprint for caching. +- If the fingerprint matches the previous run, the node may be skipped. + +Example (abbreviated): +```python +@classmethod +def fingerprint_inputs(cls, file): + path = folder_paths.get_annotated_filepath(file) + return os.path.getmtime(path) +``` + +## Usage Notes + +- Keep fingerprints fast (avoid hashing large files). +- When `INPUT_IS_LIST` is used, inputs may be wrapped (e.g., `(None,)`). diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5dc46e6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..687deb0 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test & Build + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Use Python from .python-version + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Sync Python dependencies + run: uv sync --locked --group dev + + - name: Run Python tests + run: uv run pytest tests/python -q + + - name: Typecheck + run: pnpm typecheck + + - name: Run tests + run: pnpm test + + - name: Build + run: pnpm build diff --git a/.github/workflows/publish_action.yaml b/.github/workflows/publish_action.yaml new file mode 100644 index 0000000..48bd80b --- /dev/null +++ b/.github/workflows/publish_action.yaml @@ -0,0 +1,169 @@ +name: Publish to Comfy registry + +on: + workflow_dispatch: + inputs: + bump_type: + description: "Version bump type" + required: true + type: choice + options: + - patch + - minor + - major + default: patch + +jobs: + test: + name: Test before publish + runs-on: ubuntu-latest + if: github.actor == github.repository_owner + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Use Python from .python-version + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Sync Python dependencies + run: uv sync --locked --group dev + + - name: Run Python tests + run: uv run pytest tests/python -q + + - name: Typecheck + run: pnpm typecheck + + - name: Run tests + run: pnpm test + + - name: Build + run: pnpm build + + bump-version: + name: Bump version and create tag + runs-on: ubuntu-latest + needs: test + if: github.actor == github.repository_owner + outputs: + previous-version: ${{ steps.versions.outputs.previous-version }} + current-version: ${{ steps.versions.outputs.current-version }} + permissions: + contents: write + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Use Python from .python-version + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Configure Git for GitHub Actions bot + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Sync Python dependencies + run: uv sync --locked --group dev + + - name: Compute release version + id: versions + shell: bash + run: | + previous_version="$(uv run bump-my-version show current_version)" + current_version="$(uv run bump-my-version show --increment "${{ github.event.inputs.bump_type }}" new_version)" + { + echo "previous-version=${previous_version}" + echo "current-version=${current_version}" + } >> "$GITHUB_OUTPUT" + + - name: Bump version and refresh uv.lock + run: | + uv run bump-my-version bump --no-commit --no-tag ${{ github.event.inputs.bump_type }} + uv lock + + - name: Commit version bump + run: | + git add pyproject.toml package.json src/index.ts uv.lock + git commit -m "chore: bump version to ${{ steps.versions.outputs.current-version }} [skip ci]" + + - name: Create release tag + run: git tag "v${{ steps.versions.outputs.current-version }}" -m "Release version ${{ steps.versions.outputs.current-version }}" + + - name: Push version commit and tag + run: git push origin HEAD:main --follow-tags + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.versions.outputs.current-version }} + name: Release v${{ steps.versions.outputs.current-version }} + body: | + ## Version ${{ steps.versions.outputs.current-version }} + + Bumped from version ${{ steps.versions.outputs.previous-version }} to ${{ steps.versions.outputs.current-version }} + + This release was automatically created by the version bump workflow. + draft: false + prerelease: false + generate_release_notes: true + + publish-node: + name: Publish Custom Node to registry + runs-on: ubuntu-latest + needs: bump-version + if: github.actor == github.repository_owner + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + ref: v${{ needs.bump-version.outputs.current-version }} + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Build custom node + run: pnpm build + + - name: Publish Custom Node + uses: Comfy-Org/publish-node-action@main + with: + personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} + skip_checkout: "true" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a3d905 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +__pycache__/ +dist/ +coverage/ +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.venv/ +node.zip +.vscode/ +.claude/ +docs/plans/ +.changeset/ +sounds/custom/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..162676c --- /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..1e43ba1 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# comfyui-ping + +`comfyui-ping` plays a sound in your ComfyUI browser tab when a workflow finishes. + +You can use it globally for every run, or drop a `Ping` node into a workflow when you want local overrides. + +Created with [comfyui-custom-node-template](https://github.com/PBandDev/comfyui-custom-node-template). + +## Features + +- Different sounds for success and failure +- Global notifications or node-based notifications +- Sound preview buttons in settings +- Custom sound uploads +- Queue modes for `every prompt` or `queue drained` + +## Install + +### ComfyUI Manager + +1. Open `Custom Nodes Manager` +2. Search and install `comfyui-ping` +3. Restart ComfyUI + +## Use + +Open ComfyUI settings and find `comfyui-ping`. + +From there you can: + +- turn notifications on or off +- choose success and failure sounds +- preview sounds +- upload a custom sound +- adjust volume +- choose whether notifications fire on every prompt or only when the queue finishes + +If you want workflow-specific behavior, add the `Ping` node to your graph and use its overrides. + +## Bundled sounds + +- `beep-ping.wav` +- `harmonic-beep.wav` +- `notification-soft.wav` +- `ping-failure.wav` +- `ping-ringtone.wav` +- `ping-success.wav` + +Defaults: + +- success: `ping-success.wav` +- failure: `ping-failure.wav` + +## Custom sounds + +Supported formats: + +- `.wav` +- `.mp3` +- `.ogg` +- `.m4a` +- `.flac` + +Maximum upload size: + +- `10 MiB` + +If a custom sound is missing or unreadable, `comfyui-ping` logs a warning and stays silent. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..7649bbf --- /dev/null +++ b/__init__.py @@ -0,0 +1,24 @@ +import sys +from pathlib import Path + + +WEB_DIRECTORY = "./dist" +NODE_CLASS_MAPPINGS = None +NODE_DISPLAY_NAME_MAPPINGS = None + +__all__ = [ + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", + "WEB_DIRECTORY", + "comfy_entrypoint", +] + + +def comfy_entrypoint(): + repo_root = Path(__file__).resolve().parent + if str(repo_root) not in sys.path: + sys.path.insert(0, str(repo_root)) + + from comfyui_ping.extension import comfy_entrypoint as _comfy_entrypoint + + return _comfy_entrypoint() diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..0254f05 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,58 @@ + + comfyui-ping icon + A warm notification bell with radiating ping rings. + + + + + + + + + + + + + + + + + + + + diff --git a/comfyui_ping/__init__.py b/comfyui_ping/__init__.py new file mode 100644 index 0000000..a226489 --- /dev/null +++ b/comfyui_ping/__init__.py @@ -0,0 +1,11 @@ +from .notifications import ( + NotificationPayload, + build_notification_payload, + resolve_sound_id, +) + +__all__ = [ + "NotificationPayload", + "build_notification_payload", + "resolve_sound_id", +] diff --git a/comfyui_ping/extension.py b/comfyui_ping/extension.py new file mode 100644 index 0000000..ce023fa --- /dev/null +++ b/comfyui_ping/extension.py @@ -0,0 +1,29 @@ +from comfy_api.v0_0_2 import ComfyExtension + +from .global_completion import install_global_completion_hooks +from .nodes import PingNode +from .routes import register_sound_routes +from .runtime import emit_notification, get_runtime_settings + + +class PingExtension(ComfyExtension): + def __init__(self) -> None: + self.global_completion_hooks_installed = install_global_completion_hooks( + emit_notification=emit_notification, + get_runtime_settings=get_runtime_settings, + ) + self.sound_routes_registered = register_sound_routes() + + async def get_node_list(self) -> list[type[PingNode]]: + if not self.global_completion_hooks_installed: + self.global_completion_hooks_installed = install_global_completion_hooks( + emit_notification=emit_notification, + get_runtime_settings=get_runtime_settings, + ) + if not self.sound_routes_registered: + self.sound_routes_registered = register_sound_routes() + return [PingNode] + + +def comfy_entrypoint() -> ComfyExtension: + return PingExtension() diff --git a/comfyui_ping/global_completion.py b/comfyui_ping/global_completion.py new file mode 100644 index 0000000..36e6896 --- /dev/null +++ b/comfyui_ping/global_completion.py @@ -0,0 +1,126 @@ +from collections.abc import Callable + +from .notifications import ( + NotificationPayload, + build_global_notification_payload, +) +from .runtime import RuntimeSettings, build_sound_settings, should_notify_status + + +def should_notify_global( + notify_mode: str, + queue_size_after_completion: int, +) -> bool: + if notify_mode == "every_prompt": + return True + return queue_size_after_completion == 0 + + +def build_global_completion_payload( + status: str, + global_settings: dict[str, str], + *, + volume: float = 0.8, + source: str = "queue_drained", +) -> NotificationPayload: + return build_global_notification_payload( + status=status, + global_settings=global_settings, + volume=volume, + source=source, + ) + + +def resolve_global_completion_payload( + status: str, + queue_size_after_completion: int, + runtime_settings: RuntimeSettings, +) -> NotificationPayload | None: + if not should_notify_global( + runtime_settings["notify_mode"], + queue_size_after_completion, + ): + return None + + if not should_notify_status(runtime_settings, status): + return None + + return build_global_completion_payload( + status=status, + global_settings=build_sound_settings(runtime_settings), + volume=runtime_settings["volume"], + source=runtime_settings["notify_mode"], + ) + + +def install_global_completion_hooks( + emit_notification: Callable[[NotificationPayload], None] | None = None, + get_runtime_settings: Callable[[], RuntimeSettings] | None = None, +) -> bool: + try: + import execution + except ImportError: + return False + + prompt_queue_cls = getattr(execution, "PromptQueue", None) + if prompt_queue_cls is None: + return False + + hook_config = getattr(prompt_queue_cls, "_comfyui_ping_hook_config", None) + if hook_config is None: + hook_config = { + "emit_notification": None, + "get_runtime_settings": None, + } + setattr(prompt_queue_cls, "_comfyui_ping_hook_config", hook_config) + + if emit_notification is not None: + hook_config["emit_notification"] = emit_notification + if get_runtime_settings is not None: + hook_config["get_runtime_settings"] = get_runtime_settings + + if getattr(prompt_queue_cls, "_comfyui_ping_hook_installed", False): + return True + + original_task_done = prompt_queue_cls.task_done + + def wrapped_task_done( + self, + item_id, + history_result, + status, + process_item=None, + **kwargs, + ): + if process_item is None and not kwargs: + result = original_task_done(self, item_id, history_result, status) + else: + result = original_task_done( + self, + item_id, + history_result, + status, + process_item=process_item, + **kwargs, + ) + + current_config = getattr(prompt_queue_cls, "_comfyui_ping_hook_config", {}) + current_emit_notification = current_config.get("emit_notification") + current_get_runtime_settings = current_config.get("get_runtime_settings") + + if current_emit_notification is None or current_get_runtime_settings is None: + return result + + payload = resolve_global_completion_payload( + status=getattr(status, "status_str", "success"), + queue_size_after_completion=len(getattr(self, "queue", [])), + runtime_settings=current_get_runtime_settings(), + ) + if payload is not None: + current_emit_notification(payload) + + return result + + prompt_queue_cls.task_done = wrapped_task_done + setattr(prompt_queue_cls, "_comfyui_ping_hook_installed", True) + return True diff --git a/comfyui_ping/nodes.py b/comfyui_ping/nodes.py new file mode 100644 index 0000000..6298656 --- /dev/null +++ b/comfyui_ping/nodes.py @@ -0,0 +1,47 @@ +from comfy_api.v0_0_2 import io + +from .notifications import build_notification_payload +from .runtime import emit_notification + + +class PingNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="PingNode", + display_name="Ping", + category="comfyui-ping", + description="Build a notification payload for the current workflow run.", + is_output_node=True, + inputs=[ + io.String.Input("success_sound"), + io.String.Input("failure_sound"), + io.Float.Input("volume", min=0.0, max=1.0, step=0.05), + io.Boolean.Input("enabled"), + ], + outputs=[io.AnyType.Output("notification")], + ) + + @classmethod + def execute( + cls, + success_sound: str, + failure_sound: str, + volume: float, + enabled: bool, + ) -> io.NodeOutput: + payload = build_notification_payload( + event_kind="node", + status="success", + global_settings={ + "success_sound": success_sound, + "failure_sound": failure_sound, + }, + node_overrides=None, + volume=volume, + ) + + if enabled: + emit_notification(payload) + + return io.NodeOutput(payload) diff --git a/comfyui_ping/notifications.py b/comfyui_ping/notifications.py new file mode 100644 index 0000000..5cfea05 --- /dev/null +++ b/comfyui_ping/notifications.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass + +PING_EVENT_NAME = "comfyui-ping.notification" +SOUND_PREFIXES = ("bundled:", "custom:") + + +@dataclass(frozen=True) +class NotificationPayload: + event_kind: str + status: str + sound_id: str | None + allow_fallback: bool + volume: float = 0.8 + source: str = "" + + +def normalize_status(status: str) -> str: + return "failure" if status == "error" else status + + +def resolve_sound_id( + status: str, + global_settings: dict[str, str], + node_overrides: dict[str, str] | None, +) -> str | None: + sound_key = f"{normalize_status(status)}_sound" + if node_overrides and node_overrides.get(sound_key): + return normalize_sound_id(node_overrides[sound_key]) + return normalize_sound_id(global_settings.get(sound_key)) + + +def normalize_sound_id(sound_id: str | None) -> str | None: + if sound_id is None or sound_id == "": + return None + if sound_id.startswith(SOUND_PREFIXES): + return sound_id + return f"bundled:{sound_id}" + + +def build_notification_payload( + event_kind: str, + status: str, + global_settings: dict[str, str], + node_overrides: dict[str, str] | None, + *, + volume: float = 0.8, + source: str | None = None, +) -> NotificationPayload: + normalized_status = normalize_status(status) + return NotificationPayload( + event_kind=event_kind, + status=normalized_status, + sound_id=resolve_sound_id( + status=normalized_status, + global_settings=global_settings, + node_overrides=node_overrides, + ), + allow_fallback=False, + volume=volume, + source=source or event_kind, + ) + + +def build_global_notification_payload( + status: str, + global_settings: dict[str, str], + *, + volume: float = 0.8, + source: str = "queue_drained", +) -> NotificationPayload: + return build_notification_payload( + event_kind="global", + status=status, + global_settings=global_settings, + node_overrides=None, + volume=volume, + source=source, + ) diff --git a/comfyui_ping/routes.py b/comfyui_ping/routes.py new file mode 100644 index 0000000..f3dbb03 --- /dev/null +++ b/comfyui_ping/routes.py @@ -0,0 +1,248 @@ +from pathlib import Path +from typing import Protocol + +from .runtime import get_runtime_settings, update_runtime_settings +from .sounds import ( + BUNDLED_SOUNDS_DIR, + CUSTOM_SOUNDS_DIR, + LEGACY_CUSTOM_SOUNDS_DIR, + MAX_UPLOAD_BYTES, + ensure_custom_sound_storage, + is_allowed_upload, + list_available_sounds, + list_bundled_sounds, + list_custom_sounds, +) + +SOUND_CATALOG_ROUTE = "/comfyui-ping/sounds" +SOUND_UPLOAD_ROUTE = "/comfyui-ping/sounds/upload" +SOUND_FILE_ROUTE = "/comfyui-ping/sounds/{source}/{filename}" +SETTINGS_ROUTE = "/comfyui-ping/settings" + +_SOUND_ROUTES_REGISTERED = False + + +class RouteRegistrar(Protocol): + def get(self, path: str): ... + def post(self, path: str): ... + + +def build_sound_catalog_payload( + *, + bundled: list[str], + custom: list[str], +) -> dict[str, list[dict[str, str]]]: + return { + "sounds": list_available_sounds( + bundled=bundled, + custom=custom, + ) + } + + +def resolve_sound_path( + *, + filename: str, + source: str, + bundled_dir: Path = BUNDLED_SOUNDS_DIR, + custom_dir: Path = CUSTOM_SOUNDS_DIR, + legacy_custom_dir: Path = LEGACY_CUSTOM_SOUNDS_DIR, +) -> Path | None: + safe_name = Path(filename).name + if source == "bundled": + candidate = bundled_dir / safe_name + return candidate if candidate.is_file() else None + if source == "custom": + ensure_custom_sound_storage( + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + bundled_dir=bundled_dir, + ) + candidate = custom_dir / safe_name + return candidate if candidate.is_file() else None + return None + + +def save_uploaded_sound( + *, + filename: str, + data: bytes, + custom_dir: Path = CUSTOM_SOUNDS_DIR, + bundled_dir: Path = BUNDLED_SOUNDS_DIR, + legacy_custom_dir: Path = LEGACY_CUSTOM_SOUNDS_DIR, +) -> Path: + if not is_allowed_upload(filename): + raise ValueError("Invalid audio file format") + if len(data) > MAX_UPLOAD_BYTES: + raise ValueError("Sound file exceeds 10 MiB limit") + + safe_name = Path(filename).name + if (bundled_dir / safe_name).is_file(): + raise ValueError("Filename collides with bundled sound") + + ensure_custom_sound_storage( + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + bundled_dir=bundled_dir, + ) + target_path = custom_dir / safe_name + if target_path.is_file(): + raise ValueError("Filename already exists in custom sounds") + target_path.write_bytes(data) + return target_path + + +def list_sounds_route_payload( + bundled_dir: Path = BUNDLED_SOUNDS_DIR, + custom_dir: Path = CUSTOM_SOUNDS_DIR, + legacy_custom_dir: Path = LEGACY_CUSTOM_SOUNDS_DIR, +) -> dict[str, list[dict[str, str]]]: + ensure_custom_sound_storage( + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + bundled_dir=bundled_dir, + ) + return build_sound_catalog_payload( + bundled=list_bundled_sounds(bundled_dir), + custom=list_custom_sounds(custom_dir), + ) + + +def runtime_settings_route_payload() -> dict[str, object]: + return get_runtime_settings() + + +def serve_sound_route_path( + filename: str, + source: str, + bundled_dir: Path = BUNDLED_SOUNDS_DIR, + custom_dir: Path = CUSTOM_SOUNDS_DIR, + legacy_custom_dir: Path = LEGACY_CUSTOM_SOUNDS_DIR, +) -> Path | None: + return resolve_sound_path( + filename=filename, + source=source, + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + + +def attach_sound_routes( + *, + routes: RouteRegistrar, + web_module: object, + bundled_dir: Path = BUNDLED_SOUNDS_DIR, + custom_dir: Path = CUSTOM_SOUNDS_DIR, + legacy_custom_dir: Path = LEGACY_CUSTOM_SOUNDS_DIR, +) -> None: + @routes.get(SOUND_CATALOG_ROUTE) + async def list_sounds(_request): + return web_module.json_response( + list_sounds_route_payload( + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + ) + + @routes.get(SETTINGS_ROUTE) + async def get_runtime_settings_route(_request): + return web_module.json_response(runtime_settings_route_payload()) + + @routes.post(SETTINGS_ROUTE) + async def update_runtime_settings_route(request): + payload = await request.json() + if not isinstance(payload, dict): + return web_module.json_response( + {"error": "Invalid settings payload"}, + status=400, + ) + + return web_module.json_response(update_runtime_settings(payload)) + + @routes.get(SOUND_FILE_ROUTE) + async def serve_sound(request): + path = serve_sound_route_path( + filename=request.match_info.get("filename", ""), + source=request.match_info.get("source", ""), + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + if path is None: + raise web_module.HTTPNotFound() + + return web_module.FileResponse(path) + + @routes.post(SOUND_UPLOAD_ROUTE) + async def upload_sound(request): + form_data = await request.post() + upload = form_data.get("file") + filename = getattr(upload, "filename", None) + file_handle = getattr(upload, "file", None) + if not isinstance(filename, str) or file_handle is None: + return web_module.json_response( + {"error": "Missing sound file"}, + status=400, + ) + + try: + data = file_handle.read() + if not isinstance(data, bytes): + raise ValueError("Missing sound file") + + save_uploaded_sound( + filename=filename, + data=data, + custom_dir=custom_dir, + bundled_dir=bundled_dir, + legacy_custom_dir=legacy_custom_dir, + ) + except ValueError as exc: + return web_module.json_response( + {"error": str(exc)}, + status=400, + ) + + return web_module.json_response( + list_sounds_route_payload( + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ), + status=201, + ) + + +def register_sound_routes( + *, + bundled_dir: Path = BUNDLED_SOUNDS_DIR, + custom_dir: Path = CUSTOM_SOUNDS_DIR, + legacy_custom_dir: Path = LEGACY_CUSTOM_SOUNDS_DIR, +) -> bool: + global _SOUND_ROUTES_REGISTERED + + if _SOUND_ROUTES_REGISTERED: + return True + + try: + from aiohttp import web + from server import PromptServer + except ImportError: + return False + + prompt_server = getattr(PromptServer, "instance", None) + routes = getattr(prompt_server, "routes", None) + if routes is None: + return False + + attach_sound_routes( + routes=routes, + web_module=web, + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + _SOUND_ROUTES_REGISTERED = True + return True diff --git a/comfyui_ping/runtime.py b/comfyui_ping/runtime.py new file mode 100644 index 0000000..4cafcde --- /dev/null +++ b/comfyui_ping/runtime.py @@ -0,0 +1,87 @@ +from dataclasses import asdict +from typing import TypedDict + +from .notifications import NotificationPayload, PING_EVENT_NAME, normalize_status + + +class RuntimeSettings(TypedDict): + enabled: bool + notify_mode: str + success_enabled: bool + failure_enabled: bool + success_sound: str + failure_sound: str + volume: float + + +DEFAULT_RUNTIME_SETTINGS: RuntimeSettings = { + "enabled": True, + "notify_mode": "queue_drained", + "success_enabled": True, + "failure_enabled": True, + "success_sound": "bundled:ping-success.wav", + "failure_sound": "bundled:ping-failure.wav", + "volume": 0.8, +} + +_runtime_settings: RuntimeSettings = dict(DEFAULT_RUNTIME_SETTINGS) + + +def get_runtime_settings() -> RuntimeSettings: + return dict(_runtime_settings) + + +def update_runtime_settings(settings: dict[str, object]) -> RuntimeSettings: + updated_settings = get_runtime_settings() + + for key in ("enabled", "success_enabled", "failure_enabled"): + value = settings.get(key) + if isinstance(value, bool): + updated_settings[key] = value + + notify_mode = settings.get("notify_mode") + if notify_mode in {"every_prompt", "queue_drained"}: + updated_settings["notify_mode"] = notify_mode + + for key in ("success_sound", "failure_sound"): + value = settings.get(key) + if isinstance(value, str) and value: + updated_settings[key] = value + + volume = settings.get("volume") + if isinstance(volume, (int, float)): + updated_settings["volume"] = max(0.0, min(float(volume), 1.0)) + + _runtime_settings.update(updated_settings) + return get_runtime_settings() + + +def build_sound_settings(settings: RuntimeSettings) -> dict[str, str]: + return { + "success_sound": settings["success_sound"], + "failure_sound": settings["failure_sound"], + } + + +def should_notify_status(settings: RuntimeSettings, status: str) -> bool: + if not settings["enabled"]: + return False + + normalized_status = normalize_status(status) + if normalized_status == "success": + return settings["success_enabled"] + return settings["failure_enabled"] + + +def emit_notification(payload: NotificationPayload) -> bool: + try: + from server import PromptServer + except ImportError: + return False + + prompt_server = getattr(PromptServer, "instance", None) + if prompt_server is None: + return False + + prompt_server.send_sync(PING_EVENT_NAME, asdict(payload)) + return True diff --git a/comfyui_ping/sounds.py b/comfyui_ping/sounds.py new file mode 100644 index 0000000..8c972e6 --- /dev/null +++ b/comfyui_ping/sounds.py @@ -0,0 +1,84 @@ +from collections.abc import Iterable +from pathlib import Path +from typing import Literal, TypedDict + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SOUNDS_DIR = REPO_ROOT / "sounds" +BUNDLED_SOUNDS_DIR = SOUNDS_DIR / "bundled" +CUSTOM_SOUNDS_DIR = SOUNDS_DIR / "custom" +LEGACY_CUSTOM_SOUNDS_DIR = REPO_ROOT / "input" / "ping" +ALLOWED_EXTENSIONS = {".wav", ".mp3", ".ogg", ".m4a", ".flac"} +MAX_UPLOAD_BYTES = 10 * 1024 * 1024 + + +class SoundCatalogEntry(TypedDict): + name: str + source: Literal["bundled", "custom"] + + +def is_allowed_upload(filename: str) -> bool: + return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS + + +def list_available_sounds( + *, + bundled: Iterable[str], + custom: Iterable[str], +) -> list[SoundCatalogEntry]: + return [ + *( + {"name": name, "source": "bundled"} + for name in bundled + ), + *( + {"name": name, "source": "custom"} + for name in custom + ), + ] + + +def list_sound_files(directory: Path) -> list[str]: + if not directory.exists(): + return [] + + return sorted( + file.name + for file in directory.iterdir() + if file.is_file() and is_allowed_upload(file.name) + ) + + +def list_bundled_sounds(bundled_dir: Path = BUNDLED_SOUNDS_DIR) -> list[str]: + return list_sound_files(bundled_dir) + + +def list_custom_sounds(custom_dir: Path = CUSTOM_SOUNDS_DIR) -> list[str]: + return list_sound_files(custom_dir) + + +def ensure_custom_sound_storage( + custom_dir: Path = CUSTOM_SOUNDS_DIR, + legacy_custom_dir: Path = LEGACY_CUSTOM_SOUNDS_DIR, + bundled_dir: Path = BUNDLED_SOUNDS_DIR, +) -> Path: + custom_dir.mkdir(parents=True, exist_ok=True) + if not legacy_custom_dir.exists(): + return custom_dir + + for legacy_file in legacy_custom_dir.iterdir(): + if not legacy_file.is_file() or not is_allowed_upload(legacy_file.name): + continue + + if (bundled_dir / legacy_file.name).exists(): + legacy_file.unlink(missing_ok=True) + continue + + target_path = custom_dir / legacy_file.name + if target_path.exists(): + legacy_file.unlink(missing_ok=True) + continue + + legacy_file.replace(target_path) + + return custom_dir diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8edea0 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "comfyui-ping", + "version": "1.0.0", + "description": "Browser-played workflow success and failure audio notifications for ComfyUI", + "type": "module", + "scripts": { + "dev": "tsc && vite build --watch", + "build": "tsc && vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "keywords": [ + "comfyui", + "custom-node", + "workflow", + "notifications", + "audio", + "extension" + ], + "author": "pbanddev", + "license": "AGPL-3.0", + "homepage": "https://github.com/PBandDev/comfyui-ping", + "bugs": { + "url": "https://github.com/PBandDev/comfyui-ping/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/PBandDev/comfyui-ping.git" + }, + "packageManager": "pnpm@10.17.1", + "dependencies": { + "typescript": "^5.9.3", + "vite": "^7.1.12" + }, + "devDependencies": { + "@comfyorg/comfyui-frontend-types": "^1.32.1", + "@types/node": "^24.10.0", + "@vitest/coverage-v8": "^4.0.16", + "vitest": "^4.0.16" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..a81ede1 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1222 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.1.12 + version: 7.3.0(@types/node@24.10.4) + devDependencies: + '@comfyorg/comfyui-frontend-types': + specifier: ^1.32.1 + version: 1.36.3(vue@3.5.25(typescript@5.9.3))(zod@3.25.76) + '@types/node': + specifier: ^24.10.0 + version: 24.10.4 + '@vitest/coverage-v8': + specifier: ^4.0.16 + version: 4.0.16(vitest@4.0.16(@types/node@24.10.4)) + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@24.10.4) + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@comfyorg/comfyui-frontend-types@1.36.3': + resolution: {integrity: sha512-eWYkcEtFCUpv6gbsvFHLILpyXETjNKkffS9vcciaAreKDYSe5CuatCnrtw4FScMYcs9iHB2KiZGIBhVQGYVlXg==} + peerDependencies: + vue: ^3.5.13 + zod: ^3.23.8 + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.53.5': + resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.5': + resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.5': + resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.5': + resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.5': + resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.5': + resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.5': + resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.53.5': + resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.53.5': + resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.53.5': + resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.5': + resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.53.5': + resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.53.5': + resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.53.5': + resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.53.5': + resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.53.5': + resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.53.5': + resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.5': + resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.5': + resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.5': + resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.5': + resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.5': + resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@24.10.4': + resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} + + '@vitest/coverage-v8@4.0.16': + resolution: {integrity: sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==} + peerDependencies: + '@vitest/browser': 4.0.16 + vitest: 4.0.16 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.0.16': + resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + + '@vitest/mocker@4.0.16': + resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.16': + resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + + '@vitest/runner@4.0.16': + resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + + '@vitest/snapshot@4.0.16': + resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + + '@vitest/spy@4.0.16': + resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + + '@vitest/utils@4.0.16': + resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + + '@vue/compiler-core@3.5.25': + resolution: {integrity: sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==} + + '@vue/compiler-dom@3.5.25': + resolution: {integrity: sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==} + + '@vue/compiler-sfc@3.5.25': + resolution: {integrity: sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==} + + '@vue/compiler-ssr@3.5.25': + resolution: {integrity: sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==} + + '@vue/reactivity@3.5.25': + resolution: {integrity: sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==} + + '@vue/runtime-core@3.5.25': + resolution: {integrity: sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==} + + '@vue/runtime-dom@3.5.25': + resolution: {integrity: sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==} + + '@vue/server-renderer@3.5.25': + resolution: {integrity: sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==} + peerDependencies: + vue: 3.5.25 + + '@vue/shared@3.5.25': + resolution: {integrity: sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@0.3.10: + resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.1: + resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.53.5: + resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.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 + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.16: + resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.16 + '@vitest/browser-preview': 4.0.16 + '@vitest/browser-webdriverio': 4.0.16 + '@vitest/ui': 4.0.16 + happy-dom: '*' + jsdom: '*' + 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/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vue@3.5.25: + resolution: {integrity: sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@comfyorg/comfyui-frontend-types@1.36.3(vue@3.5.25(typescript@5.9.3))(zod@3.25.76)': + dependencies: + vue: 3.5.25(typescript@5.9.3) + zod: 3.25.76 + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.53.5': + optional: true + + '@rollup/rollup-android-arm64@4.53.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.5': + optional: true + + '@rollup/rollup-darwin-x64@4.53.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.53.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.53.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.5': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@24.10.4': + dependencies: + undici-types: 7.16.0 + + '@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@24.10.4))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.16 + ast-v8-to-istanbul: 0.3.10 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magicast: 0.5.1 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.16(@types/node@24.10.4) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.0.16': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4))': + dependencies: + '@vitest/spy': 4.0.16 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@24.10.4) + + '@vitest/pretty-format@4.0.16': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.16': + dependencies: + '@vitest/utils': 4.0.16 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.16': {} + + '@vitest/utils@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + tinyrainbow: 3.0.3 + + '@vue/compiler-core@3.5.25': + dependencies: + '@babel/parser': 7.28.5 + '@vue/shared': 3.5.25 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.25': + dependencies: + '@vue/compiler-core': 3.5.25 + '@vue/shared': 3.5.25 + + '@vue/compiler-sfc@3.5.25': + dependencies: + '@babel/parser': 7.28.5 + '@vue/compiler-core': 3.5.25 + '@vue/compiler-dom': 3.5.25 + '@vue/compiler-ssr': 3.5.25 + '@vue/shared': 3.5.25 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.25': + dependencies: + '@vue/compiler-dom': 3.5.25 + '@vue/shared': 3.5.25 + + '@vue/reactivity@3.5.25': + dependencies: + '@vue/shared': 3.5.25 + + '@vue/runtime-core@3.5.25': + dependencies: + '@vue/reactivity': 3.5.25 + '@vue/shared': 3.5.25 + + '@vue/runtime-dom@3.5.25': + dependencies: + '@vue/reactivity': 3.5.25 + '@vue/runtime-core': 3.5.25 + '@vue/shared': 3.5.25 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.25(vue@3.5.25(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.25 + '@vue/shared': 3.5.25 + vue: 3.5.25(typescript@5.9.3) + + '@vue/shared@3.5.25': {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.10: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + + chai@6.2.2: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + entities@4.5.0: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@9.0.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.1: + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.53.5: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.5 + '@rollup/rollup-android-arm64': 4.53.5 + '@rollup/rollup-darwin-arm64': 4.53.5 + '@rollup/rollup-darwin-x64': 4.53.5 + '@rollup/rollup-freebsd-arm64': 4.53.5 + '@rollup/rollup-freebsd-x64': 4.53.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.5 + '@rollup/rollup-linux-arm-musleabihf': 4.53.5 + '@rollup/rollup-linux-arm64-gnu': 4.53.5 + '@rollup/rollup-linux-arm64-musl': 4.53.5 + '@rollup/rollup-linux-loong64-gnu': 4.53.5 + '@rollup/rollup-linux-ppc64-gnu': 4.53.5 + '@rollup/rollup-linux-riscv64-gnu': 4.53.5 + '@rollup/rollup-linux-riscv64-musl': 4.53.5 + '@rollup/rollup-linux-s390x-gnu': 4.53.5 + '@rollup/rollup-linux-x64-gnu': 4.53.5 + '@rollup/rollup-linux-x64-musl': 4.53.5 + '@rollup/rollup-openharmony-arm64': 4.53.5 + '@rollup/rollup-win32-arm64-msvc': 4.53.5 + '@rollup/rollup-win32-ia32-msvc': 4.53.5 + '@rollup/rollup-win32-x64-gnu': 4.53.5 + '@rollup/rollup-win32-x64-msvc': 4.53.5 + fsevents: 2.3.3 + + semver@7.7.3: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + vite@7.3.0(@types/node@24.10.4): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.5 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.4 + fsevents: 2.3.3 + + vitest@4.0.16(@types/node@24.10.4): + dependencies: + '@vitest/expect': 4.0.16 + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)) + '@vitest/pretty-format': 4.0.16 + '@vitest/runner': 4.0.16 + '@vitest/snapshot': 4.0.16 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + es-module-lexer: 1.7.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: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.0(@types/node@24.10.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.4 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vue@3.5.25(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.25 + '@vue/compiler-sfc': 3.5.25 + '@vue/runtime-dom': 3.5.25 + '@vue/server-renderer': 3.5.25(vue@3.5.25(typescript@5.9.3)) + '@vue/shared': 3.5.25 + optionalDependencies: + typescript: 5.9.3 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..efc037a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - esbuild diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..86ffc0c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "comfyui-ping" +description = "Browser-played workflow success and failure audio notifications for ComfyUI" +version = "1.0.0" +requires-python = ">=3.12" +readme = "README.md" +license = { file = "LICENSE" } +classifiers = [ + "Operating System :: OS Independent", +] + +[dependency-groups] +dev = [ + "bump-my-version>=1.2.2", + "pytest>=8.4.2", +] + +[project.urls] +Repository = "https://github.com/PBandDev/comfyui-ping" +Documentation = "https://github.com/PBandDev/comfyui-ping#readme" +"Bug Tracker" = "https://github.com/PBandDev/comfyui-ping/issues" + +[tool.comfy] +PublisherId = "pbanddev" +DisplayName = "comfyui-ping" +Icon = "https://raw.githubusercontent.com/PBandDev/comfyui-ping/main/assets/icon.svg" +requires-comfyui = ">=0.3.0" +includes = ['dist/'] + +[tool.bumpversion] +current_version = "1.0.0" +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" +serialize = ["{major}.{minor}.{patch}"] +commit = false +tag = false +tag_name = "v{new_version}" +tag_message = "Release version {new_version}" +message = "chore: bump version to {new_version} [skip ci]" + +[[tool.bumpversion.files]] +filename = "pyproject.toml" +search = 'version = "{current_version}"' +replace = 'version = "{new_version}"' + +[[tool.bumpversion.files]] +filename = "package.json" +search = '"version": "{current_version}"' +replace = '"version": "{new_version}"' + +[[tool.bumpversion.files]] +filename = "src/index.ts" +search = 'Version {current_version}' +replace = 'Version {new_version}' diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..8f9c764 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "comfyui-nodes-dev": { + "source": "jimlee2048/skills", + "sourceType": "github", + "computedHash": "80f0435fb9fc11de834cb52eee28cfe96977b99aac5b4c8fac48d16203dea8d4" + } + } +} diff --git a/sounds/bundled/beep-ping.wav b/sounds/bundled/beep-ping.wav new file mode 100644 index 0000000000000000000000000000000000000000..7effda641b016afa10ee1d1f7893f0bb7c53987d GIT binary patch literal 22128 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 literal 0 HcmV?d00001 diff --git a/sounds/bundled/harmonic-beep.wav b/sounds/bundled/harmonic-beep.wav new file mode 100644 index 0000000000000000000000000000000000000000..235652a27f3acad96bf207b1fd4ce4f0913249b5 GIT binary patch literal 9050 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 literal 0 HcmV?d00001 diff --git a/sounds/bundled/notification-soft.wav b/sounds/bundled/notification-soft.wav new file mode 100644 index 0000000000000000000000000000000000000000..105ce779ec3d2b50acf96973c611575bc4b520d6 GIT binary patch literal 18600 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 literal 0 HcmV?d00001 diff --git a/sounds/bundled/ping-failure.wav b/sounds/bundled/ping-failure.wav new file mode 100644 index 0000000000000000000000000000000000000000..be3f27acd674098b3e7b937da1a1fc57f523a899 GIT binary patch literal 12392 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 Promise; +} + +export interface SoundCatalogEntry { + name: string; + source: SoundSource; +} + +export interface SoundCatalogPayload { + sounds: SoundCatalogEntry[]; +} + +export type SoundOptionId = `${SoundSource}:${string}`; +export interface RuntimeNotificationSettings { + enabled: boolean; + notify_mode: "every_prompt" | "queue_drained"; + success_enabled: boolean; + failure_enabled: boolean; + success_sound: string; + failure_sound: string; + volume: number; +} + +export const SOUND_CATALOG_ROUTE = "/comfyui-ping/sounds"; +export const SOUND_UPLOAD_ROUTE = "/comfyui-ping/sounds/upload"; +export const SETTINGS_ROUTE = "/comfyui-ping/settings"; + +export function toSoundOptionId(entry: SoundCatalogEntry): SoundOptionId { + return `${entry.source}:${entry.name}`; +} + +export function normalizeSoundOptionId( + soundId: string | null | undefined +): SoundOptionId | null { + if (!soundId) { + return null; + } + + if (soundId.includes(":")) { + return toSoundOptionId(parseSoundOptionId(soundId)); + } + + return `bundled:${soundId}`; +} + +export function parseSoundOptionId(soundId: string): SoundCatalogEntry { + const separatorIndex = soundId.indexOf(":"); + const source = soundId.slice(0, separatorIndex); + const name = soundId.slice(separatorIndex + 1); + + if ( + source !== SOUND_SOURCES.BUNDLED && + source !== SOUND_SOURCES.CUSTOM + ) { + throw new Error(`Unsupported sound source: ${source}`); + } + + return { + name, + source, + }; +} + +export function buildSoundCatalogRoute(): string { + return SOUND_CATALOG_ROUTE; +} + +export function buildSoundUploadRoute(): string { + return SOUND_UPLOAD_ROUTE; +} + +export function buildSettingsRoute(): string { + return SETTINGS_ROUTE; +} + +export function buildSoundFileRoute(entry: SoundCatalogEntry): string { + return `${SOUND_CATALOG_ROUTE}/${encodeURIComponent(entry.source)}/${encodeURIComponent(entry.name)}`; +} + +export function parseSoundCatalogPayload(input: { + sounds?: Array<{ + name?: string; + source?: string; + }>; +}): SoundCatalogPayload { + const sounds = input.sounds ?? []; + + return { + sounds: sounds.flatMap((entry) => { + if ( + typeof entry.name !== "string" || + (entry.source !== SOUND_SOURCES.BUNDLED && + entry.source !== SOUND_SOURCES.CUSTOM) + ) { + return []; + } + + return [ + { + name: entry.name, + source: entry.source, + }, + ]; + }), + }; +} + +async function parseCatalogResponse( + response: Response +): Promise { + if (!response.ok) { + throw new Error(`Sound catalog request failed with status ${response.status}`); + } + + const payload: { + sounds?: Array<{ + name?: string; + source?: string; + }>; + } = await response.json(); + + return parseSoundCatalogPayload(payload); +} + +export async function fetchSoundCatalog( + client: FetchApiClient +): Promise { + return parseCatalogResponse( + await client.fetchApi(buildSoundCatalogRoute(), undefined) + ); +} + +export async function uploadSoundFile( + client: FetchApiClient, + file: File +): Promise { + const formData = new FormData(); + formData.append("file", file); + + return parseCatalogResponse( + await client.fetchApi(buildSoundUploadRoute(), { + body: formData, + method: "POST", + }) + ); +} + +export async function storeRuntimeSettings( + client: FetchApiClient, + settings: RuntimeNotificationSettings +): Promise { + const response = await client.fetchApi(buildSettingsRoute(), { + body: JSON.stringify(settings), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + + if (!response.ok) { + throw new Error(`Settings request failed with status ${response.status}`); + } + + return (await response.json()) as RuntimeNotificationSettings; +} diff --git a/src/audio.ts b/src/audio.ts new file mode 100644 index 0000000..32abcdf --- /dev/null +++ b/src/audio.ts @@ -0,0 +1,162 @@ +import { + buildSoundFileRoute, + normalizeSoundOptionId, + parseSoundOptionId, + type SoundCatalogEntry, + type SoundOptionId, +} from "./api"; + +export interface ManagedAudio { + currentTime: number; + pause?: () => void; + play: () => Promise | void; + volume: number; +} + +export interface NotificationLogger { + warn: (message: string) => void; +} + +export interface ShouldPlayNotificationInput { + selectedSound: SoundOptionId | string | null; + soundExists: boolean; + isCustom: boolean; +} + +export interface PlayNotificationInput extends ShouldPlayNotificationInput { + audioUrl: string | null; + createAudio: (audioUrl: string) => ManagedAudio; + currentAudio?: ManagedAudio | null; + logger?: NotificationLogger; + stopPrevious?: boolean; + volume: number; +} + +export interface PlayNotificationResult { + audio: ManagedAudio | null; + played: boolean; +} + +export interface NotificationEventDetail { + event_kind: string; + status: "success" | "failure"; + sound_id: SoundOptionId | string | null; + volume: number; + source: string; +} +export function catalogHasSound( + catalog: SoundCatalogEntry[] | undefined, + entry: SoundCatalogEntry +): boolean { + if (!catalog) { + return true; + } + + return catalog.some( + (catalogEntry) => + catalogEntry.name === entry.name && catalogEntry.source === entry.source + ); +} + +export function resolveNotificationAudioUrl( + soundId: NotificationEventDetail["sound_id"] +): string | null { + const normalizedSoundId = normalizeSoundOptionId(soundId); + if (!normalizedSoundId) { + return null; + } + + return buildSoundFileRoute(parseSoundOptionId(normalizedSoundId)); +} + +export function shouldPlayNotification( + input: ShouldPlayNotificationInput +): boolean { + if (!input.selectedSound) { + return false; + } + + if (input.isCustom && !input.soundExists) { + return false; + } + + return input.soundExists || !input.isCustom; +} + +export async function playNotification( + input: PlayNotificationInput +): Promise { + if (!shouldPlayNotification(input) || !input.audioUrl) { + if (input.currentAudio) { + input.currentAudio.pause?.(); + input.currentAudio.currentTime = 0; + } + + if (input.isCustom && input.selectedSound && !input.soundExists) { + input.logger?.warn( + `Selected custom sound '${input.selectedSound}' is unavailable; skipping playback.` + ); + } + + return { + audio: null, + played: false, + }; + } + + if (input.stopPrevious !== false && input.currentAudio) { + input.currentAudio.pause?.(); + input.currentAudio.currentTime = 0; + } + + const audio = input.createAudio(input.audioUrl); + audio.volume = input.volume; + try { + await audio.play(); + } catch (error) { + input.logger?.warn( + `Unable to play notification audio: ${ + error instanceof Error ? error.message : String(error) + }` + ); + + return { + audio: null, + played: false, + }; + } + + return { + audio, + played: true, + }; +} + +export async function handleNotificationEvent(input: { + catalog?: SoundCatalogEntry[]; + createAudio: (audioUrl: string) => ManagedAudio; + currentAudio?: ManagedAudio | null; + detail: NotificationEventDetail; + logger?: NotificationLogger; + stopPrevious?: boolean; +}): Promise { + const selectedSound = normalizeSoundOptionId(input.detail.sound_id); + const parsedSound = selectedSound ? parseSoundOptionId(selectedSound) : null; + const isCustom = parsedSound?.source === "custom"; + const audioUrl = resolveNotificationAudioUrl(selectedSound); + const soundExists = parsedSound + ? catalogHasSound(input.catalog, parsedSound) + : false; + + return playNotification({ + audioUrl, + createAudio: input.createAudio, + currentAudio: input.currentAudio, + isCustom, + logger: input.logger, + selectedSound, + soundExists, + stopPrevious: input.stopPrevious, + volume: input.detail.volume, + }); +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..5ffb72f --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,10 @@ +export const SETTINGS_PREFIX = "comfyui-ping"; +export const LOGGING_PREFIX = `[${SETTINGS_PREFIX}]`; +export const SOUND_SOURCES = { + BUNDLED: "bundled", + CUSTOM: "custom", +} as const; +export const SETTINGS_IDS = { + VERSION: `${SETTINGS_PREFIX}.Version`, + DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`, +}; diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..4672cef --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,32 @@ +import { LOGGING_PREFIX, SETTINGS_IDS } from "./constants"; + +/** Check if debug logging is enabled */ +export function isDebugEnabled(): boolean { + try { + // Access app only if it exists (not in test environment) + if (typeof globalThis !== "undefined" && "app" in globalThis) { + const app = ( + globalThis as { + app?: { + extensionManager?: { setting?: { get: (id: string) => T } }; + }; + } + ).app; + return ( + app?.extensionManager?.setting?.get( + SETTINGS_IDS.DEBUG_LOGGING + ) ?? false + ); + } + return false; + } catch { + return false; + } +} + +/** Log message only if debug logging is enabled */ +export function debugLog(message: string, ...args: unknown[]): void { + if (isDebugEnabled()) { + console.log(`${LOGGING_PREFIX} ${message}`, ...args); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c9300bd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,660 @@ +import type { ComfyApp } from "@comfyorg/comfyui-frontend-types"; +import { + handleNotificationEvent, + type ManagedAudio, + type NotificationEventDetail, +} from "./audio"; +import { + fetchSoundCatalog, + storeRuntimeSettings, + toSoundOptionId, + uploadSoundFile, + type FetchApiClient, + type RuntimeNotificationSettings, + type SoundCatalogPayload, + type SoundOptionId, +} from "./api"; +import { SETTINGS_IDS, SETTINGS_PREFIX } from "./constants"; + +declare global { + const app: ComfyApp; + + interface Window { + app: ComfyApp; + } +} + +type PingNotifyMode = "every_prompt" | "queue_drained"; +type PingSettingValue = string | number | boolean | undefined; +type PingSettingRenderer = ( + name: string, + setter: (value: unknown) => void, + value: unknown, + attrs?: Record +) => HTMLElement; +type PingSettingType = + | "boolean" + | "slider" + | "combo" + | PingSettingRenderer; + +interface PingSettingOption { + text: string; + value: string; +} + +interface PingSetting { + id: string; + name: string; + type: PingSettingType; + defaultValue: PingSettingValue; + onChange?: (newValue: unknown, oldValue?: unknown) => void; + tooltip?: string; + attrs?: Record; + options?: PingSettingOption[]; +} + +interface PingAppApi extends FetchApiClient { + addEventListener: ( + eventName: string, + listener: (event: { detail: NotificationEventDetail }) => void + ) => void; +} + +interface PingAppLike { + api?: PingAppApi; + registerExtension?: (extension: unknown) => void; + ui?: { + settings: { + getSettingValue: (id: string, defaultValue?: unknown) => unknown; + settingsLookup?: Record< + string, + { + onChange?: (newValue: unknown, oldValue?: unknown) => void | Promise; + } + >; + }; + }; +} + +interface PingLogger { + warn: (message: string) => void; +} + +export const DEFAULT_SUCCESS_SOUND_ID = "bundled:ping-success.wav"; +export const DEFAULT_FAILURE_SOUND_ID = "bundled:ping-failure.wav"; +export const PING_EVENT_NAME = "comfyui-ping.notification"; +const DEFAULT_SOUND_CATALOG: SoundCatalogPayload = { + sounds: [ + { name: "beep-ping.wav", source: "bundled" }, + { name: "harmonic-beep.wav", source: "bundled" }, + { name: "notification-soft.wav", source: "bundled" }, + { name: "ping-success.wav", source: "bundled" }, + { name: "ping-failure.wav", source: "bundled" }, + { name: "ping-ringtone.wav", source: "bundled" }, + ], +}; + +export const PING_SETTINGS_IDS = { + VERSION: SETTINGS_IDS.VERSION, + DEBUG_LOGGING: SETTINGS_IDS.DEBUG_LOGGING, + GLOBAL_ENABLED: `${SETTINGS_PREFIX}.Notifications Enabled`, + NOTIFY_MODE: `${SETTINGS_PREFIX}.Notify Mode`, + SUCCESS_ENABLED: `${SETTINGS_PREFIX}.Success Enabled`, + FAILURE_ENABLED: `${SETTINGS_PREFIX}.Failure Enabled`, + SUCCESS_SOUND: `${SETTINGS_PREFIX}.Success Sound`, + FAILURE_SOUND: `${SETTINGS_PREFIX}.Failure Sound`, + UPLOAD_ACTION: `${SETTINGS_PREFIX}.Upload Action`, + VOLUME: `${SETTINGS_PREFIX}.Volume`, +} as const; + +export const PING_NOTIFY_MODES: PingSettingOption[] = [ + { + text: "Every Prompt", + value: "every_prompt", + }, + { + text: "Queue Drained", + value: "queue_drained", + }, +]; + +function createHomepageRenderer(): HTMLSpanElement { + const spanEl = document.createElement("span"); + const linkEl = document.createElement("a"); + linkEl.href = "https://github.com/PBandDev/comfyui-ping"; + linkEl.target = "_blank"; + linkEl.rel = "noopener noreferrer"; + linkEl.style.paddingRight = "12px"; + linkEl.textContent = "Homepage"; + spanEl.append(linkEl); + return spanEl; +} + +function coerceSettingString( + value: unknown, + fallbackValue: SoundOptionId +): string { + return typeof value === "string" && value.length > 0 ? value : fallbackValue; +} + +function coerceSettingBoolean(value: unknown, fallbackValue: boolean): boolean { + return typeof value === "boolean" ? value : fallbackValue; +} + +function coerceSettingVolume(value: unknown, fallbackValue: number): number { + if (typeof value !== "number" || Number.isNaN(value)) { + return fallbackValue; + } + + return Math.min(1, Math.max(0, value)); +} + +export function buildSoundSettingOptions( + catalog: SoundCatalogPayload +): PingSettingOption[] { + return catalog.sounds.map((entry) => ({ + text: `${entry.source === "bundled" ? "Bundled" : "Custom"} / ${entry.name}`, + value: toSoundOptionId(entry), + })); +} + +function buildUnavailableSoundOption(soundId: string): PingSettingOption { + const separatorIndex = soundId.indexOf(":"); + const source = separatorIndex === -1 ? "" : soundId.slice(0, separatorIndex); + const name = separatorIndex === -1 ? soundId : soundId.slice(separatorIndex + 1); + const sourceLabel = + source === "custom" ? "Custom" : source === "bundled" ? "Bundled" : "Sound"; + + return { + text: `${sourceLabel} / ${name} (unavailable)`, + value: soundId, + }; +} + +export function buildRenderedSoundOptions( + options: PingSettingOption[], + selectedValue: string +): PingSettingOption[] { + if (selectedValue.length === 0) { + return options; + } + + if (options.some((option) => option.value === selectedValue)) { + return options; + } + + return [buildUnavailableSoundOption(selectedValue), ...options]; +} + +function findSetting(settingId: string): PingSetting | undefined { + return PING_SETTINGS.find((setting) => setting.id === settingId); +} + +let currentSoundCatalog = DEFAULT_SOUND_CATALOG; +const soundCatalogSubscribers = new Set< + (catalog: SoundCatalogPayload) => void +>(); + +function subscribeToSoundCatalog( + listener: (catalog: SoundCatalogPayload) => void +): () => void { + soundCatalogSubscribers.add(listener); + listener(currentSoundCatalog); + return () => { + soundCatalogSubscribers.delete(listener); + }; +} + +function notifySoundCatalogSubscribers(): void { + for (const listener of soundCatalogSubscribers) { + listener(currentSoundCatalog); + } +} + +export function applySoundCatalogToSettings( + catalog: SoundCatalogPayload +): SoundCatalogPayload { + currentSoundCatalog = catalog; + const options = buildSoundSettingOptions(catalog); + + for (const settingId of [ + PING_SETTINGS_IDS.SUCCESS_SOUND, + PING_SETTINGS_IDS.FAILURE_SOUND, + ]) { + const setting = findSetting(settingId); + if (setting) { + setting.options = options; + } + } + + notifySoundCatalogSubscribers(); + return currentSoundCatalog; +} + +function populateSoundSelect( + selectEl: HTMLSelectElement, + options: PingSettingOption[], + selectedValue: string +): void { + const renderedOptions = buildRenderedSoundOptions(options, selectedValue); + selectEl.replaceChildren(); + + for (const option of renderedOptions) { + const optionEl = document.createElement("option"); + optionEl.textContent = option.text; + optionEl.value = option.value; + optionEl.selected = option.value === selectedValue; + selectEl.append(optionEl); + } + + if (selectedValue.length > 0) { + selectEl.value = selectedValue; + } else if (selectEl.options.length > 0 && selectEl.selectedIndex === -1) { + selectEl.value = selectEl.options[0].value; + } +} + +function createSoundSelectRenderer( + settingId: string, + fallbackValue: SoundOptionId +): PingSettingRenderer { + return (name: string, setter: (value: unknown) => void, value: unknown) => { + const wrapperEl = document.createElement("label"); + const labelEl = document.createElement("span"); + const controlsEl = document.createElement("div"); + const selectEl = document.createElement("select"); + const playButtonEl = document.createElement("button"); + wrapperEl.style.display = "flex"; + wrapperEl.style.flexDirection = "column"; + wrapperEl.style.gap = "6px"; + controlsEl.style.display = "flex"; + controlsEl.style.gap = "8px"; + selectEl.style.flex = "1"; + labelEl.textContent = name; + playButtonEl.type = "button"; + playButtonEl.textContent = "Play"; + selectEl.addEventListener("change", () => { + const runtimeApp = getRuntimeApp(); + const settingsUi = runtimeApp?.ui?.settings; + const nextValue = selectEl.value; + const previousValue = settingsUi?.getSettingValue(settingId, fallbackValue); + + setter(nextValue); + + const onChange = settingsUi?.settingsLookup?.[settingId]?.onChange; + if (onChange) { + void onChange(nextValue, previousValue); + } + }); + playButtonEl.addEventListener("click", () => { + const runtimeApp = getRuntimeApp(); + if (!runtimeApp) { + return; + } + + void previewSoundSelection( + runtimeApp, + selectEl.value || coerceSettingString(value, fallbackValue) + ); + }); + + subscribeToSoundCatalog((catalog) => { + const selectedValue = + selectEl.value || coerceSettingString(value, fallbackValue); + populateSoundSelect( + selectEl, + buildSoundSettingOptions(catalog), + selectedValue + ); + }); + + controlsEl.append(selectEl, playButtonEl); + wrapperEl.append(labelEl, controlsEl); + return wrapperEl; + }; +} + +export async function refreshSoundCatalog( + comfyApp: PingAppLike +): Promise { + if (!comfyApp.api) { + return null; + } + + return applySoundCatalogToSettings(await fetchSoundCatalog(comfyApp.api)); +} + +export async function uploadCustomSound( + comfyApp: PingAppLike, + file: File +): Promise { + if (!comfyApp.api) { + return null; + } + + return applySoundCatalogToSettings(await uploadSoundFile(comfyApp.api, file)); +} + +export function readRuntimeNotificationSettings( + comfyApp: PingAppLike +): RuntimeNotificationSettings | null { + const settingsUi = comfyApp.ui?.settings; + if (!settingsUi) { + return null; + } + + return { + enabled: coerceSettingBoolean( + settingsUi.getSettingValue(PING_SETTINGS_IDS.GLOBAL_ENABLED, true), + true + ), + failure_enabled: coerceSettingBoolean( + settingsUi.getSettingValue(PING_SETTINGS_IDS.FAILURE_ENABLED, true), + true + ), + failure_sound: coerceSettingString( + settingsUi.getSettingValue( + PING_SETTINGS_IDS.FAILURE_SOUND, + DEFAULT_FAILURE_SOUND_ID + ), + DEFAULT_FAILURE_SOUND_ID + ), + notify_mode: + settingsUi.getSettingValue( + PING_SETTINGS_IDS.NOTIFY_MODE, + "queue_drained" + ) === "every_prompt" + ? "every_prompt" + : "queue_drained", + success_enabled: coerceSettingBoolean( + settingsUi.getSettingValue(PING_SETTINGS_IDS.SUCCESS_ENABLED, true), + true + ), + success_sound: coerceSettingString( + settingsUi.getSettingValue( + PING_SETTINGS_IDS.SUCCESS_SOUND, + DEFAULT_SUCCESS_SOUND_ID + ), + DEFAULT_SUCCESS_SOUND_ID + ), + volume: coerceSettingVolume( + settingsUi.getSettingValue(PING_SETTINGS_IDS.VOLUME, 0.8), + 0.8 + ), + }; +} + +export async function syncRuntimeSettings( + comfyApp: PingAppLike +): Promise { + if (!comfyApp.api) { + return null; + } + + const settings = readRuntimeNotificationSettings(comfyApp); + if (!settings) { + return null; + } + + return storeRuntimeSettings(comfyApp.api, settings); +} + +async function previewSoundSelection( + comfyApp: PingAppLike, + soundId: string, + logger: PingLogger = getLogger() +): Promise { + const settingsUi = comfyApp.ui?.settings; + if (!settingsUi) { + return; + } + + const result = await handleNotificationEvent({ + catalog: currentSoundCatalog.sounds, + createAudio: createBrowserAudio, + currentAudio: currentNotificationAudio, + detail: { + event_kind: "preview", + sound_id: soundId, + source: "settings", + status: "success", + volume: coerceSettingVolume( + settingsUi.getSettingValue(PING_SETTINGS_IDS.VOLUME, 0.8), + 0.8 + ), + }, + logger, + }); + + currentNotificationAudio = result.audio; +} + +async function trySyncRuntimeSettings( + comfyApp: PingAppLike, + logger: PingLogger = getLogger() +): Promise { + try { + return await syncRuntimeSettings(comfyApp); + } catch (error) { + logger.warn( + `Unable to sync runtime settings: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } +} + +export async function tryUploadCustomSound( + comfyApp: PingAppLike, + file: File, + logger: PingLogger = getLogger() +): Promise { + try { + return await uploadCustomSound(comfyApp, file); + } catch (error) { + logger.warn( + `Unable to upload custom sound: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } +} + +function getRuntimeApp(): PingAppLike | null { + return typeof app === "undefined" ? null : (app as PingAppLike); +} + +function createSettingsSyncOnChange(): ( + newValue: unknown, + oldValue?: unknown +) => void { + return () => { + const runtimeApp = getRuntimeApp(); + if (!runtimeApp) { + return; + } + + void trySyncRuntimeSettings(runtimeApp); + }; +} + +function createUploadActionRenderer(): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Upload Custom Sound"; + button.addEventListener("click", () => { + const runtimeApp = getRuntimeApp(); + if (!runtimeApp?.api) { + return; + } + + const inputEl = document.createElement("input"); + inputEl.type = "file"; + inputEl.accept = ".wav,.mp3,.ogg,.m4a,.flac,audio/*"; + inputEl.addEventListener( + "change", + () => { + const file = inputEl.files?.item(0); + if (!file) { + return; + } + + void tryUploadCustomSound(runtimeApp, file); + }, + { once: true } + ); + inputEl.click(); + }); + return button; +} + +export const PING_SETTINGS: PingSetting[] = [ + { + id: PING_SETTINGS_IDS.VERSION, + name: "Version 1.0.0", + type: createHomepageRenderer, + defaultValue: undefined, + }, + { + id: PING_SETTINGS_IDS.DEBUG_LOGGING, + name: "Enable Debug Logging", + type: "boolean", + tooltip: "Show detailed debug logs in browser console during operation", + defaultValue: false, + }, + { + id: PING_SETTINGS_IDS.GLOBAL_ENABLED, + name: "Enable Workflow Notifications", + type: "boolean", + defaultValue: true, + onChange: createSettingsSyncOnChange(), + }, + { + id: PING_SETTINGS_IDS.NOTIFY_MODE, + name: "Global Notify Mode", + type: "combo", + defaultValue: "queue_drained" satisfies PingNotifyMode, + options: PING_NOTIFY_MODES, + onChange: createSettingsSyncOnChange(), + }, + { + id: PING_SETTINGS_IDS.SUCCESS_ENABLED, + name: "Notify On Success", + type: "boolean", + defaultValue: true, + onChange: createSettingsSyncOnChange(), + }, + { + id: PING_SETTINGS_IDS.FAILURE_ENABLED, + name: "Notify On Failure", + type: "boolean", + defaultValue: true, + onChange: createSettingsSyncOnChange(), + }, + { + id: PING_SETTINGS_IDS.SUCCESS_SOUND, + name: "Success Sound", + type: createSoundSelectRenderer( + PING_SETTINGS_IDS.SUCCESS_SOUND, + DEFAULT_SUCCESS_SOUND_ID + ), + defaultValue: DEFAULT_SUCCESS_SOUND_ID, + options: buildSoundSettingOptions(DEFAULT_SOUND_CATALOG), + onChange: createSettingsSyncOnChange(), + }, + { + id: PING_SETTINGS_IDS.FAILURE_SOUND, + name: "Failure Sound", + type: createSoundSelectRenderer( + PING_SETTINGS_IDS.FAILURE_SOUND, + DEFAULT_FAILURE_SOUND_ID + ), + defaultValue: DEFAULT_FAILURE_SOUND_ID, + options: buildSoundSettingOptions(DEFAULT_SOUND_CATALOG), + onChange: createSettingsSyncOnChange(), + }, + { + id: PING_SETTINGS_IDS.UPLOAD_ACTION, + name: "Upload Custom Sound", + type: createUploadActionRenderer, + defaultValue: undefined, + tooltip: "Upload a custom browser-played notification sound.", + }, + { + id: PING_SETTINGS_IDS.VOLUME, + name: "Notification Volume", + type: "slider", + defaultValue: 0.8, + onChange: createSettingsSyncOnChange(), + attrs: { + min: 0, + max: 1, + step: 0.05, + }, + }, +]; + +let currentNotificationAudio: ManagedAudio | null = null; +let subscribedNotificationApi: PingAppApi | null = null; + +function createBrowserAudio(audioUrl: string): HTMLAudioElement { + return new Audio(audioUrl); +} + +function getLogger(): PingLogger { + return { + warn: (message: string) => { + console.warn(message); + }, + }; +} + +export function subscribeToPingNotificationEvents( + comfyApp: PingAppLike +): void { + if (!comfyApp.api || subscribedNotificationApi === comfyApp.api) { + return; + } + + subscribedNotificationApi = comfyApp.api; + comfyApp.api.addEventListener(PING_EVENT_NAME, (event) => { + void handleNotificationEvent({ + catalog: currentSoundCatalog.sounds, + createAudio: createBrowserAudio, + currentAudio: currentNotificationAudio, + detail: event.detail, + logger: getLogger(), + }).then((result) => { + currentNotificationAudio = result.audio; + }); + }); +} + +export const PING_EXTENSION = { + name: "comfyui-ping", + settings: PING_SETTINGS, + async setup() { + if (typeof app !== "undefined") { + try { + await refreshSoundCatalog(app as PingAppLike); + } catch (error) { + getLogger().warn( + `Unable to refresh sound catalog: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + await trySyncRuntimeSettings(app as PingAppLike); + + subscribeToPingNotificationEvents(app as PingAppLike); + } + }, +}; + +if (typeof app !== "undefined") { + app.registerExtension(PING_EXTENSION); +} diff --git a/src/resolve-sound.ts b/src/resolve-sound.ts new file mode 100644 index 0000000..879cbde --- /dev/null +++ b/src/resolve-sound.ts @@ -0,0 +1,32 @@ +import type { SoundOptionId } from "./api"; + +type NotificationStatus = "success" | "failure"; + +interface SoundSettings { + successSound?: SoundOptionId; + failureSound?: SoundOptionId; +} + +interface ResolveSoundSelectionInput { + status: NotificationStatus; + nodeOverrides?: SoundSettings; + globalSettings: SoundSettings; +} + +export function resolveSoundSelection( + input: ResolveSoundSelectionInput +): SoundOptionId | null { + if (input.status === "success") { + return ( + input.nodeOverrides?.successSound ?? + input.globalSettings.successSound ?? + null + ); + } + + return ( + input.nodeOverrides?.failureSound ?? + input.globalSettings.failureSound ?? + null + ); +} diff --git a/tests/api.test.ts b/tests/api.test.ts new file mode 100644 index 0000000..1915db7 --- /dev/null +++ b/tests/api.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + buildSoundCatalogRoute, + buildSoundFileRoute, + buildSettingsRoute, + buildSoundUploadRoute, + fetchSoundCatalog, + parseSoundCatalogPayload, + parseSoundOptionId, + storeRuntimeSettings, + toSoundOptionId, + type SoundCatalogEntry, +} from "../src/api"; + +describe("sound API helpers", () => { + it("creates a stable option id from a source-aware catalog entry", () => { + const entry: SoundCatalogEntry = { + name: "ping-success.wav", + source: "bundled", + }; + + expect(toSoundOptionId(entry)).toBe("bundled:ping-success.wav"); + }); + + it("parses an option id back into a source-aware entry", () => { + expect(parseSoundOptionId("custom:my-sound.wav")).toEqual({ + name: "my-sound.wav", + source: "custom", + }); + }); + + it("rejects unsupported sound sources", () => { + expect(() => parseSoundOptionId("unknown:my-sound.wav")).toThrow( + "Unsupported sound source: unknown" + ); + }); + + it("builds the real backend routes for catalog, file, and upload requests", () => { + expect(buildSoundCatalogRoute()).toBe("/comfyui-ping/sounds"); + expect(buildSoundUploadRoute()).toBe("/comfyui-ping/sounds/upload"); + expect(buildSettingsRoute()).toBe("/comfyui-ping/settings"); + expect( + buildSoundFileRoute({ + name: "my sound.wav", + source: "custom", + }) + ).toBe("/comfyui-ping/sounds/custom/my%20sound.wav"); + }); + + it("parses the source-aware catalog payload", () => { + expect( + parseSoundCatalogPayload({ + sounds: [{ name: "custom.wav", source: "custom" }], + }) + ).toEqual({ + sounds: [{ name: "custom.wav", source: "custom" }], + }); + }); + + it("loads the sound catalog through fetchApi", async () => { + const fetchApi = async (): Promise => + new Response( + JSON.stringify({ + sounds: [{ name: "uploaded.wav", source: "custom" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + await expect(fetchSoundCatalog({ fetchApi })).resolves.toEqual({ + sounds: [{ name: "uploaded.wav", source: "custom" }], + }); + }); + + it("stores runtime notification settings through fetchApi", async () => { + const settings = { + enabled: true, + failure_enabled: false, + failure_sound: "bundled:ping-failure.wav", + notify_mode: "queue_drained", + success_enabled: true, + success_sound: "custom:uploaded.wav", + volume: 0.45, + } as const; + + const fetchApi = async ( + _route: string, + options?: RequestInit + ): Promise => + new Response(options?.body as BodyInit, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + await expect( + storeRuntimeSettings({ fetchApi }, settings) + ).resolves.toEqual(settings); + }); +}); diff --git a/tests/audio.test.ts b/tests/audio.test.ts new file mode 100644 index 0000000..686dc62 --- /dev/null +++ b/tests/audio.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from "vitest"; +import { + handleNotificationEvent, + playNotification, + shouldPlayNotification, +} from "../src/audio"; + +describe("shouldPlayNotification", () => { + it("returns false when selected custom sound is invalid", () => { + expect( + shouldPlayNotification({ + selectedSound: "custom:missing.wav", + soundExists: false, + isCustom: true, + }) + ).toBe(false); + }); + + it("returns true for bundled sounds when a selection exists", () => { + expect( + shouldPlayNotification({ + selectedSound: "bundled:ping-success.wav", + soundExists: true, + isCustom: false, + }) + ).toBe(true); + }); +}); + +describe("playNotification", () => { + it("warns and plays nothing for an invalid custom sound", async () => { + const warn = vi.fn(); + const createAudio = vi.fn(); + + const result = await playNotification({ + audioUrl: "unused", + createAudio, + isCustom: true, + logger: { warn }, + selectedSound: "custom:missing.wav", + soundExists: false, + volume: 0.7, + }); + + expect(result.played).toBe(false); + expect(createAudio).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("custom sound") + ); + }); + + it("clears stale current audio for an invalid custom sound", async () => { + const pause = vi.fn(); + const warn = vi.fn(); + const currentAudio = { + currentTime: 9, + pause, + play: vi.fn(), + volume: 0.2, + }; + + const result = await playNotification({ + audioUrl: "unused", + createAudio: vi.fn(), + currentAudio, + isCustom: true, + logger: { warn }, + selectedSound: "custom:missing.wav", + soundExists: false, + volume: 0.7, + }); + + expect(pause).toHaveBeenCalledTimes(1); + expect(currentAudio.currentTime).toBe(0); + expect(result.played).toBe(false); + expect(result.audio).toBeNull(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("custom sound") + ); + }); + + it("stops the previous audio before playing a new sound", async () => { + const pause = vi.fn(); + const play = vi.fn().mockResolvedValue(undefined); + const currentAudio = { + currentTime: 12, + pause, + play: vi.fn(), + volume: 0, + }; + const nextAudio = { + currentTime: 0, + pause: vi.fn(), + play, + volume: 0, + }; + const createAudio = vi.fn(() => nextAudio); + + const result = await playNotification({ + audioUrl: "memory://ping-success", + createAudio, + currentAudio, + isCustom: false, + selectedSound: "bundled:ping-success.wav", + soundExists: true, + stopPrevious: true, + volume: 0.4, + }); + + expect(pause).toHaveBeenCalledTimes(1); + expect(currentAudio.currentTime).toBe(0); + expect(nextAudio.volume).toBe(0.4); + expect(play).toHaveBeenCalledTimes(1); + expect(result.played).toBe(true); + expect(result.audio).toBe(nextAudio); + }); + + it("warns and returns a non-playing result when audio playback rejects", async () => { + const warn = vi.fn(); + const pause = vi.fn(); + const play = vi.fn().mockRejectedValue(new Error("autoplay blocked")); + const currentAudio = { + currentTime: 4, + pause, + play: vi.fn(), + volume: 0.3, + }; + const nextAudio = { + currentTime: 0, + pause: vi.fn(), + play, + volume: 0, + }; + + const result = await playNotification({ + audioUrl: "memory://ping-failure", + createAudio: vi.fn(() => nextAudio), + currentAudio, + isCustom: false, + logger: { warn }, + selectedSound: "bundled:ping-failure.wav", + soundExists: true, + volume: 0.5, + }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Unable to play notification audio") + ); + expect(result.played).toBe(false); + expect(result.audio).toBeNull(); + }); +}); + +describe("handleNotificationEvent", () => { + it("plays custom sounds through the real backend sound route", async () => { + const play = vi.fn().mockResolvedValue(undefined); + const audio = { + currentTime: 0, + pause: vi.fn(), + play, + volume: 0, + }; + const createAudio = vi.fn(() => audio); + + const result = await handleNotificationEvent({ + catalog: [{ name: "uploaded.wav", source: "custom" }], + createAudio, + detail: { + event_kind: "global", + sound_id: "custom:uploaded.wav", + source: "queue_drained", + status: "success", + volume: 0.8, + }, + }); + + expect(createAudio).toHaveBeenCalledWith( + "/comfyui-ping/sounds/custom/uploaded.wav" + ); + expect(play).toHaveBeenCalledTimes(1); + expect(result.played).toBe(true); + }); + + it("warns and stays silent for custom sounds when the catalog is empty", async () => { + const warn = vi.fn(); + const createAudio = vi.fn(); + + const result = await handleNotificationEvent({ + catalog: [], + createAudio, + detail: { + event_kind: "global", + sound_id: "custom:uploaded.wav", + source: "queue_drained", + status: "success", + volume: 0.8, + }, + logger: { warn }, + }); + + expect(createAudio).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("custom sound") + ); + expect(result.played).toBe(false); + }); + + it("still plays bundled sounds when the catalog is empty", async () => { + const play = vi.fn().mockResolvedValue(undefined); + const audio = { + currentTime: 0, + pause: vi.fn(), + play, + volume: 0, + }; + const createAudio = vi.fn(() => audio); + + const result = await handleNotificationEvent({ + catalog: [], + createAudio, + detail: { + event_kind: "global", + sound_id: "bundled:ping-success.wav", + source: "queue_drained", + status: "success", + volume: 0.8, + }, + }); + + expect(createAudio).toHaveBeenCalledWith( + "/comfyui-ping/sounds/bundled/ping-success.wav" + ); + expect(play).toHaveBeenCalledTimes(1); + expect(result.played).toBe(true); + }); +}); diff --git a/tests/example.test.ts b/tests/example.test.ts new file mode 100644 index 0000000..77afda2 --- /dev/null +++ b/tests/example.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("ping extension registration", () => { + it("registers the comfyui-ping extension", async () => { + const registerExtension = vi.fn(); + + vi.stubGlobal("app", { + registerExtension, + }); + + await import("../src/index"); + + expect(registerExtension).toHaveBeenCalledTimes(1); + expect(registerExtension).toHaveBeenCalledWith( + expect.objectContaining({ + name: "comfyui-ping", + }) + ); + + vi.unstubAllGlobals(); + }); +}); diff --git a/tests/python/fixtures/example_node.py b/tests/python/fixtures/example_node.py new file mode 100644 index 0000000..6fd6194 --- /dev/null +++ b/tests/python/fixtures/example_node.py @@ -0,0 +1,26 @@ +class ExampleNormalizeTextNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ( + "STRING", + { + "default": "", + "multiline": True, + }, + ) + } + } + + RETURN_TYPES = ("STRING",) + FUNCTION = "normalize_text" + CATEGORY = "examples/testing" + + def normalize_text(self, text: str): + normalized_parts = [part.strip() for part in text.splitlines() if part.strip()] + return (" ".join(normalized_parts),) + + +NODE_CLASS_MAPPINGS = {"ExampleNormalizeText": ExampleNormalizeTextNode} +NODE_DISPLAY_NAME_MAPPINGS = {"ExampleNormalizeText": "Example Normalize Text"} diff --git a/tests/python/test_bundled_sounds.py b/tests/python/test_bundled_sounds.py new file mode 100644 index 0000000..0b61385 --- /dev/null +++ b/tests/python/test_bundled_sounds.py @@ -0,0 +1,42 @@ +import hashlib +import wave +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SOUNDS_DIR = REPO_ROOT / "sounds" / "bundled" + + +def wav_peak_amplitude(path: Path) -> int: + with wave.open(str(path), "rb") as wav_file: + frames = wav_file.readframes(wav_file.getnframes()) + sample_width = wav_file.getsampwidth() + if sample_width != 2: + msg = f"Unsupported sample width for {path.name}: {sample_width}" + raise AssertionError(msg) + + peak = 0 + for index in range(0, len(frames), sample_width): + sample = int.from_bytes( + frames[index : index + sample_width], + byteorder="little", + signed=True, + ) + peak = max(peak, abs(sample)) + + return peak + + +def file_sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_bundled_sounds_are_audible_and_distinct(): + success_path = SOUNDS_DIR / "ping-success.wav" + failure_path = SOUNDS_DIR / "ping-failure.wav" + + assert success_path.is_file() + assert failure_path.is_file() + assert wav_peak_amplitude(success_path) > 0 + assert wav_peak_amplitude(failure_path) > 0 + assert file_sha256(success_path) != file_sha256(failure_path) diff --git a/tests/python/test_entrypoint_smoke.py b/tests/python/test_entrypoint_smoke.py new file mode 100644 index 0000000..5bbaab4 --- /dev/null +++ b/tests/python/test_entrypoint_smoke.py @@ -0,0 +1,50 @@ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import json + + +REPO_ROOT = Path(__file__).resolve().parents[2] +ENTRYPOINT_PATH = REPO_ROOT / "__init__.py" +PACKAGE_JSON_PATH = REPO_ROOT / "package.json" +PYPROJECT_PATH = REPO_ROOT / "pyproject.toml" + + +def load_module_from_path(module_name: str, module_path: Path): + spec = spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_template_entrypoint_exports_expected_symbols(): + module = load_module_from_path("template_entrypoint", ENTRYPOINT_PATH) + + assert module.WEB_DIRECTORY == "./dist" + assert module.NODE_CLASS_MAPPINGS is None + assert module.NODE_DISPLAY_NAME_MAPPINGS is None + assert module.__all__ == [ + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", + "WEB_DIRECTORY", + "comfy_entrypoint", + ] + + +def test_project_metadata_replaces_template_defaults(): + package_json = json.loads(PACKAGE_JSON_PATH.read_text(encoding="utf-8")) + pyproject_text = PYPROJECT_PATH.read_text(encoding="utf-8") + + assert package_json["name"] == "comfyui-ping" + assert package_json["description"] == ( + "Browser-played workflow success and failure audio notifications for ComfyUI" + ) + assert 'name = "comfyui-ping"' in pyproject_text + assert ( + 'description = "Browser-played workflow success and failure audio notifications for ComfyUI"' + in pyproject_text + ) + assert 'PublisherId = "pbanddev"' in pyproject_text + assert 'DisplayName = "comfyui-ping"' in pyproject_text diff --git a/tests/python/test_example_node_pattern.py b/tests/python/test_example_node_pattern.py new file mode 100644 index 0000000..2d8c46f --- /dev/null +++ b/tests/python/test_example_node_pattern.py @@ -0,0 +1,28 @@ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + + +FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "example_node.py" + + +def load_fixture_module(): + spec = spec_from_file_location("example_node_fixture", FIXTURE_PATH) + assert spec is not None + assert spec.loader is not None + + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_example_node_shows_how_to_unit_test_pure_python_logic(): + module = load_fixture_module() + node = module.ExampleNormalizeTextNode() + + assert module.NODE_CLASS_MAPPINGS == { + "ExampleNormalizeText": module.ExampleNormalizeTextNode + } + assert module.NODE_DISPLAY_NAME_MAPPINGS == { + "ExampleNormalizeText": "Example Normalize Text" + } + assert node.normalize_text(" alpha \n\n beta \n gamma ") == ("alpha beta gamma",) diff --git a/tests/python/test_global_completion.py b/tests/python/test_global_completion.py new file mode 100644 index 0000000..c8e4c16 --- /dev/null +++ b/tests/python/test_global_completion.py @@ -0,0 +1,177 @@ +import sys +import types +from contextlib import contextmanager +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + + +from comfyui_ping.global_completion import ( + build_global_completion_payload, + install_global_completion_hooks, + resolve_global_completion_payload, + should_notify_global, +) + + +@contextmanager +def fake_execution_module(prompt_queue_cls: type): + original_execution = sys.modules.get("execution") + execution_module = types.ModuleType("execution") + execution_module.PromptQueue = prompt_queue_cls + sys.modules["execution"] = execution_module + try: + yield + finally: + if original_execution is None: + sys.modules.pop("execution", None) + else: + sys.modules["execution"] = original_execution + + +def test_queue_drained_mode_skips_nonfinal_prompt(): + assert should_notify_global( + notify_mode="queue_drained", + queue_size_after_completion=2, + ) is False + + +def test_every_prompt_mode_notifies_each_completion(): + assert should_notify_global( + notify_mode="every_prompt", + queue_size_after_completion=2, + ) is True + + +def test_build_global_completion_payload_uses_global_event_kind(): + payload = build_global_completion_payload( + status="failure", + global_settings={"failure_sound": "global-failure.wav"}, + ) + + assert payload.event_kind == "global" + assert payload.status == "failure" + assert payload.sound_id == "bundled:global-failure.wav" + + +def test_resolve_global_completion_payload_normalizes_error_status(): + payload = resolve_global_completion_payload( + status="error", + queue_size_after_completion=0, + runtime_settings={ + "enabled": True, + "notify_mode": "queue_drained", + "success_enabled": True, + "failure_enabled": True, + "success_sound": "ping-success.wav", + "failure_sound": "custom:uploaded.wav", + "volume": 0.45, + }, + ) + + assert payload is not None + assert payload.status == "failure" + assert payload.sound_id == "custom:uploaded.wav" + assert payload.volume == 0.45 + + +def test_resolve_global_completion_payload_skips_disabled_failure_notifications(): + payload = resolve_global_completion_payload( + status="error", + queue_size_after_completion=0, + runtime_settings={ + "enabled": True, + "notify_mode": "queue_drained", + "success_enabled": True, + "failure_enabled": False, + "success_sound": "ping-success.wav", + "failure_sound": "ping-failure.wav", + "volume": 0.8, + }, + ) + + assert payload is None + + +def test_reinstall_updates_callbacks_after_noop_install(): + emitted_payloads = [] + + class FakeStatus: + status_str = "success" + + class FakePromptQueue: + def __init__(self): + self.queue = [] + self.calls = [] + + def task_done(self, item_id, history_result, status): + self.calls.append((item_id, history_result, status.status_str)) + return "done" + + with fake_execution_module(FakePromptQueue): + assert install_global_completion_hooks() is True + assert install_global_completion_hooks( + emit_notification=emitted_payloads.append, + get_runtime_settings=lambda: { + "enabled": True, + "notify_mode": "queue_drained", + "success_enabled": True, + "failure_enabled": True, + "success_sound": "global-success.wav", + "failure_sound": "global-failure.wav", + "volume": 0.65, + }, + ) is True + + queue = FakePromptQueue() + result = queue.task_done("item-1", {"history": True}, FakeStatus()) + + assert result == "done" + assert emitted_payloads[0].event_kind == "global" + assert emitted_payloads[0].status == "success" + assert emitted_payloads[0].sound_id == "bundled:global-success.wav" + assert emitted_payloads[0].volume == 0.65 + assert len(emitted_payloads) == 1 + + +def test_wrapped_task_done_accepts_process_item_keyword(): + emitted_payloads = [] + + class FakeStatus: + status_str = "success" + + class FakePromptQueue: + def __init__(self): + self.queue = [] + self.calls = [] + + def task_done(self, item_id, history_result, status, process_item=None): + self.calls.append(process_item) + return "done" + + with fake_execution_module(FakePromptQueue): + assert install_global_completion_hooks( + emit_notification=emitted_payloads.append, + get_runtime_settings=lambda: { + "enabled": True, + "notify_mode": "queue_drained", + "success_enabled": True, + "failure_enabled": True, + "success_sound": "global-success.wav", + "failure_sound": "global-failure.wav", + "volume": 0.8, + }, + ) is True + + queue = FakePromptQueue() + result = queue.task_done( + "item-1", + {"history": True}, + FakeStatus(), + process_item=lambda prompt: prompt, + ) + + assert result == "done" + assert len(emitted_payloads) == 1 diff --git a/tests/python/test_notification_policy.py b/tests/python/test_notification_policy.py new file mode 100644 index 0000000..3a69574 --- /dev/null +++ b/tests/python/test_notification_policy.py @@ -0,0 +1,67 @@ +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + + +from comfyui_ping import build_notification_payload, resolve_sound_id + + +def test_notification_payload_includes_status_and_sound_id(): + payload = build_notification_payload( + event_kind="global", + status="failure", + global_settings={"failure_sound": "buzz.wav"}, + node_overrides=None, + ) + + assert payload.status == "failure" + assert payload.sound_id == "bundled:buzz.wav" + assert payload.volume == 0.8 + assert payload.source == "global" + + +def test_notification_policy_prefers_node_override(): + payload = build_notification_payload( + event_kind="node", + status="success", + global_settings={"success_sound": "global-success.wav"}, + node_overrides={"success_sound": "node-success.wav"}, + ) + + assert payload.sound_id == "bundled:node-success.wav" + + +def test_invalid_custom_sound_does_not_fallback(): + payload = build_notification_payload( + event_kind="global", + status="failure", + global_settings={"failure_sound": "missing.wav"}, + node_overrides=None, + ) + + assert payload.allow_fallback is False + + +def test_resolve_sound_id_uses_global_value_without_override(): + assert ( + resolve_sound_id( + status="failure", + global_settings={"failure_sound": "global-failure.wav"}, + node_overrides=None, + ) + == "bundled:global-failure.wav" + ) + + +def test_notification_policy_preserves_source_aware_sound_id(): + payload = build_notification_payload( + event_kind="node", + status="failure", + global_settings={"failure_sound": "bundled:global-failure.wav"}, + node_overrides={"failure_sound": "custom:alert.wav"}, + ) + + assert payload.sound_id == "custom:alert.wav" diff --git a/tests/python/test_ping_entrypoint.py b/tests/python/test_ping_entrypoint.py new file mode 100644 index 0000000..fa44075 --- /dev/null +++ b/tests/python/test_ping_entrypoint.py @@ -0,0 +1,210 @@ +import sys +import types +from asyncio import run +from contextlib import contextmanager +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) +ENTRYPOINT_PATH = REPO_ROOT / "__init__.py" + + +@contextmanager +def comfy_api_stubbed(): + comfy_api_module = types.ModuleType("comfy_api") + v002_module = types.ModuleType("comfy_api.v0_0_2") + original_modules = { + name: sys.modules.get(name) + for name in ( + "comfy_api", + "comfy_api.v0_0_2", + "comfyui_ping.extension", + "comfyui_ping.nodes", + ) + } + + class ComfyExtension: + async def get_node_list(self): + return [] + + class _Input: + def __init__(self, name: str, **kwargs): + self.name = name + self.kwargs = kwargs + + class _Output: + def __init__(self, name: str, **kwargs): + self.name = name + self.kwargs = kwargs + + class _String: + Input = _Input + + class _Float: + Input = _Input + + class _Boolean: + Input = _Input + + class _AnyType: + Output = _Output + + class Schema: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + class NodeOutput: + def __init__(self, *result, **kwargs): + self.result = result + self.kwargs = kwargs + + class ComfyNode: + pass + + io = types.SimpleNamespace( + Boolean=_Boolean, + ComfyNode=ComfyNode, + Float=_Float, + NodeOutput=NodeOutput, + Schema=Schema, + String=_String, + AnyType=_AnyType, + ) + + v002_module.ComfyExtension = ComfyExtension + v002_module.io = io + comfy_api_module.v0_0_2 = v002_module + + sys.modules["comfy_api"] = comfy_api_module + sys.modules["comfy_api.v0_0_2"] = v002_module + + try: + yield + finally: + for name, module in original_modules.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +def load_module_from_path(module_name: str, module_path: Path): + spec = spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_root_entrypoint_exports_v3_entrypoint_and_web_directory(): + module = load_module_from_path("ping_root_entrypoint", ENTRYPOINT_PATH) + + assert module.WEB_DIRECTORY == "./dist" + assert callable(module.comfy_entrypoint) + + +def test_comfy_entrypoint_returns_ping_extension(): + with comfy_api_stubbed(): + from comfyui_ping.extension import PingExtension + + module = load_module_from_path("ping_root_entrypoint", ENTRYPOINT_PATH) + extension = module.comfy_entrypoint() + + assert isinstance(extension, PingExtension) + + +def test_comfy_entrypoint_works_without_repo_root_on_sys_path(): + with comfy_api_stubbed(): + original_sys_path = list(sys.path) + original_extension = sys.modules.pop("comfyui_ping.extension", None) + original_package = sys.modules.pop("comfyui_ping", None) + sys.path[:] = [path for path in sys.path if path != str(REPO_ROOT)] + + try: + module = load_module_from_path( + "ping_root_entrypoint_no_repo_path", + ENTRYPOINT_PATH, + ) + extension = module.comfy_entrypoint() + finally: + sys.path[:] = original_sys_path + if original_package is not None: + sys.modules["comfyui_ping"] = original_package + if original_extension is not None: + sys.modules["comfyui_ping.extension"] = original_extension + + assert extension is not None + + +def test_ping_extension_retries_sound_route_registration_on_get_node_list(): + with comfy_api_stubbed(): + import comfyui_ping.extension as extension_module + + register_attempts: list[int] = [] + + def fake_install_global_completion_hooks( + *, + emit_notification, + get_runtime_settings, + ) -> bool: + return True + + def fake_register_sound_routes() -> bool: + register_attempts.append(len(register_attempts) + 1) + return len(register_attempts) > 1 + + original_install = extension_module.install_global_completion_hooks + original_register = extension_module.register_sound_routes + extension_module.install_global_completion_hooks = ( + fake_install_global_completion_hooks + ) + extension_module.register_sound_routes = fake_register_sound_routes + + try: + extension = extension_module.PingExtension() + + assert extension.sound_routes_registered is False + + run(extension.get_node_list()) + + assert extension.sound_routes_registered is True + assert register_attempts == [1, 2] + finally: + extension_module.install_global_completion_hooks = original_install + extension_module.register_sound_routes = original_register + + +def test_ping_extension_retries_global_hook_installation_on_get_node_list(): + with comfy_api_stubbed(): + import comfyui_ping.extension as extension_module + + hook_attempts: list[int] = [] + + def fake_install_global_completion_hooks( + *, + emit_notification, + get_runtime_settings, + ) -> bool: + hook_attempts.append(len(hook_attempts) + 1) + return len(hook_attempts) > 1 + + original_install = extension_module.install_global_completion_hooks + extension_module.install_global_completion_hooks = ( + fake_install_global_completion_hooks + ) + + try: + extension = extension_module.PingExtension() + + assert extension.global_completion_hooks_installed is False + + run(extension.get_node_list()) + + assert extension.global_completion_hooks_installed is True + assert hook_attempts == [1, 2] + finally: + extension_module.install_global_completion_hooks = original_install diff --git a/tests/python/test_ping_node.py b/tests/python/test_ping_node.py new file mode 100644 index 0000000..64f550e --- /dev/null +++ b/tests/python/test_ping_node.py @@ -0,0 +1,134 @@ +import sys +import types +from contextlib import contextmanager +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + + +@contextmanager +def comfy_api_stubbed(): + comfy_api_module = types.ModuleType("comfy_api") + v002_module = types.ModuleType("comfy_api.v0_0_2") + original_modules = { + name: sys.modules.get(name) + for name in ("comfy_api", "comfy_api.v0_0_2", "comfyui_ping.nodes") + } + + class ComfyExtension: + async def get_node_list(self): + return [] + + class _Input: + def __init__(self, name: str, **kwargs): + self.name = name + self.kwargs = kwargs + + class _Output: + def __init__(self, name: str, **kwargs): + self.name = name + self.kwargs = kwargs + + class _String: + Input = _Input + + class _Float: + Input = _Input + + class _Boolean: + Input = _Input + + class _AnyType: + Output = _Output + + class Schema: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + class NodeOutput: + def __init__(self, *result, **kwargs): + self.result = result + self.kwargs = kwargs + + class ComfyNode: + pass + + io = types.SimpleNamespace( + Boolean=_Boolean, + ComfyNode=ComfyNode, + Float=_Float, + NodeOutput=NodeOutput, + Schema=Schema, + String=_String, + AnyType=_AnyType, + ) + + v002_module.ComfyExtension = ComfyExtension + v002_module.io = io + comfy_api_module.v0_0_2 = v002_module + + sys.modules["comfy_api"] = comfy_api_module + sys.modules["comfy_api.v0_0_2"] = v002_module + + try: + yield + finally: + for name, module in original_modules.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +def test_ping_node_schema_exposes_override_inputs(): + with comfy_api_stubbed(): + from comfyui_ping.nodes import PingNode + + schema = PingNode.define_schema() + input_names = [input_def.name for input_def in schema.inputs] + + assert "success_sound" in input_names + assert "failure_sound" in input_names + assert "volume" in input_names + assert "enabled" in input_names + + +def test_ping_node_execute_emits_notification_payload(): + with comfy_api_stubbed(): + from comfyui_ping.nodes import PingNode + + output = PingNode.execute( + success_sound="ding.wav", + failure_sound="buzz.wav", + volume=0.8, + enabled=True, + ) + + assert output is not None + + +def test_ping_node_execute_emits_runtime_notification_when_enabled(): + with comfy_api_stubbed(): + import comfyui_ping.nodes as nodes_module + + emitted_payloads = [] + original_emit_notification = nodes_module.emit_notification + nodes_module.emit_notification = emitted_payloads.append + + try: + output = nodes_module.PingNode.execute( + success_sound="ding.wav", + failure_sound="buzz.wav", + volume=0.35, + enabled=True, + ) + finally: + nodes_module.emit_notification = original_emit_notification + + assert output is not None + assert len(emitted_payloads) == 1 + assert emitted_payloads[0].status == "success" + assert emitted_payloads[0].sound_id == "bundled:ding.wav" + assert emitted_payloads[0].volume == 0.35 diff --git a/tests/python/test_sound_routes.py b/tests/python/test_sound_routes.py new file mode 100644 index 0000000..a945259 --- /dev/null +++ b/tests/python/test_sound_routes.py @@ -0,0 +1,353 @@ +import sys +from asyncio import run +from io import BytesIO +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from comfyui_ping.routes import ( + SOUND_CATALOG_ROUTE, + SOUND_FILE_ROUTE, + SOUND_UPLOAD_ROUTE, + attach_sound_routes, + build_sound_catalog_payload, + resolve_sound_path, + save_uploaded_sound, +) +from comfyui_ping.sounds import MAX_UPLOAD_BYTES + + +def test_build_sound_catalog_payload_returns_sound_list(): + payload = build_sound_catalog_payload( + bundled=["ping-success.wav"], + custom=["custom.wav"], + ) + + assert payload == { + "sounds": [ + {"name": "ping-success.wav", "source": "bundled"}, + {"name": "custom.wav", "source": "custom"}, + ], + } + + +def test_list_sounds_route_payload_returns_structured_catalog(tmp_path: Path): + bundled_dir = tmp_path / "bundled" + custom_dir = tmp_path / "custom" + legacy_custom_dir = tmp_path / "legacy" + bundled_dir.mkdir() + custom_dir.mkdir() + (bundled_dir / "ping-success.wav").write_bytes(b"bundled") + (custom_dir / "custom.wav").write_bytes(b"custom") + + from comfyui_ping.routes import list_sounds_route_payload + + payload = list_sounds_route_payload( + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + + assert payload == { + "sounds": [ + {"name": "ping-success.wav", "source": "bundled"}, + {"name": "custom.wav", "source": "custom"}, + ], + } + + +def test_resolve_sound_path_uses_requested_source(): + bundled_dir = REPO_ROOT / "sounds" / "bundled" + custom_dir = REPO_ROOT / "sounds" / "custom" + legacy_custom_dir = REPO_ROOT / "input" / "ping-unused-test" + custom_dir.mkdir(parents=True, exist_ok=True) + custom_file = custom_dir / "ping-success.wav" + custom_file.write_bytes(b"custom") + + try: + bundled_path = resolve_sound_path( + filename="ping-success.wav", + source="bundled", + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + custom_path = resolve_sound_path( + filename="ping-success.wav", + source="custom", + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + finally: + custom_file.unlink(missing_ok=True) + + assert bundled_path == bundled_dir / "ping-success.wav" + assert custom_path == custom_file + + +def test_list_sounds_route_payload_migrates_legacy_custom_uploads(tmp_path: Path): + bundled_dir = tmp_path / "sounds" / "bundled" + custom_dir = tmp_path / "sounds" / "custom" + legacy_custom_dir = tmp_path / "input" / "ping" + bundled_dir.mkdir(parents=True) + legacy_custom_dir.mkdir(parents=True) + (bundled_dir / "ping-success.wav").write_bytes(b"bundled") + (legacy_custom_dir / "ringtone5.mp3").write_bytes(b"legacy") + + from comfyui_ping.routes import list_sounds_route_payload + + payload = list_sounds_route_payload( + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + + assert payload == { + "sounds": [ + {"name": "ping-success.wav", "source": "bundled"}, + {"name": "ringtone5.mp3", "source": "custom"}, + ], + } + assert (custom_dir / "ringtone5.mp3").read_bytes() == b"legacy" + assert (legacy_custom_dir / "ringtone5.mp3").exists() is False + + +def test_list_sounds_route_payload_skips_legacy_files_promoted_to_bundled( + tmp_path: Path, +): + bundled_dir = tmp_path / "sounds" / "bundled" + custom_dir = tmp_path / "sounds" / "custom" + legacy_custom_dir = tmp_path / "input" / "ping" + bundled_dir.mkdir(parents=True) + legacy_custom_dir.mkdir(parents=True) + (bundled_dir / "ping-ringtone.wav").write_bytes(b"bundled") + (legacy_custom_dir / "ping-ringtone.wav").write_bytes(b"legacy") + + from comfyui_ping.routes import list_sounds_route_payload + + payload = list_sounds_route_payload( + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + + assert payload == { + "sounds": [{"name": "ping-ringtone.wav", "source": "bundled"}], + } + assert (custom_dir / "ping-ringtone.wav").exists() is False + assert (legacy_custom_dir / "ping-ringtone.wav").exists() is False + + +def test_save_uploaded_sound_rejects_invalid_extension(tmp_path: Path): + try: + save_uploaded_sound( + filename="not-a-sound.txt", + data=b"bad", + custom_dir=tmp_path, + legacy_custom_dir=tmp_path / "legacy", + ) + except ValueError as exc: + assert str(exc) == "Invalid audio file format" + else: + raise AssertionError("save_uploaded_sound should reject invalid extensions") + + +def test_save_uploaded_sound_rejects_oversized_file(tmp_path: Path): + try: + save_uploaded_sound( + filename="too-large.wav", + data=b"0" * (MAX_UPLOAD_BYTES + 1), + custom_dir=tmp_path / "custom", + legacy_custom_dir=tmp_path / "legacy", + ) + except ValueError as exc: + assert str(exc) == "Sound file exceeds 10 MiB limit" + else: + raise AssertionError("save_uploaded_sound should reject oversized files") + + +def test_save_uploaded_sound_rejects_bundled_name_collision(tmp_path: Path): + bundled_dir = tmp_path / "bundled" + legacy_custom_dir = tmp_path / "legacy" + bundled_dir.mkdir() + (bundled_dir / "ping-success.wav").write_bytes(b"bundled") + + try: + save_uploaded_sound( + filename="ping-success.wav", + data=b"custom", + custom_dir=tmp_path / "custom", + bundled_dir=bundled_dir, + legacy_custom_dir=legacy_custom_dir, + ) + except ValueError as exc: + assert str(exc) == "Filename collides with bundled sound" + else: + raise AssertionError("save_uploaded_sound should reject bundled name collisions") + + +def test_save_uploaded_sound_rejects_existing_custom_filename(tmp_path: Path): + custom_dir = tmp_path / "custom" + legacy_custom_dir = tmp_path / "legacy" + custom_dir.mkdir() + (custom_dir / "custom.wav").write_bytes(b"old") + + try: + save_uploaded_sound( + filename="custom.wav", + data=b"new", + custom_dir=custom_dir, + bundled_dir=tmp_path / "bundled", + legacy_custom_dir=legacy_custom_dir, + ) + except ValueError as exc: + assert str(exc) == "Filename already exists in custom sounds" + else: + raise AssertionError("save_uploaded_sound should reject existing custom filenames") + + +def test_resolve_sound_path_rejects_non_files(tmp_path: Path): + bundled_dir = tmp_path / "bundled" + custom_dir = tmp_path / "custom" + legacy_custom_dir = tmp_path / "legacy" + (bundled_dir / "ping-success.wav").mkdir(parents=True) + custom_dir.mkdir() + + path = resolve_sound_path( + filename="ping-success.wav", + source="bundled", + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + + assert path is None + + +class FakeRoutes: + def __init__(self) -> None: + self.handlers: dict[tuple[str, str], object] = {} + + def get(self, path: str): + def decorator(handler): + self.handlers[("GET", path)] = handler + return handler + + return decorator + + def post(self, path: str): + def decorator(handler): + self.handlers[("POST", path)] = handler + return handler + + return decorator + + +class FakeWeb: + class HTTPNotFound(Exception): + pass + + class Response: + def __init__( + self, + *, + payload: object | None = None, + path: Path | None = None, + status: int = 200, + ) -> None: + self.payload = payload + self.path = path + self.status = status + + @staticmethod + def json_response(payload: object, status: int = 200) -> "FakeWeb.Response": + return FakeWeb.Response(payload=payload, status=status) + + @staticmethod + def FileResponse(path: Path) -> "FakeWeb.Response": + return FakeWeb.Response(path=path, status=200) + + +class FakeUploadField: + def __init__(self, *, filename: str, data: bytes) -> None: + self.filename = filename + self.file = BytesIO(data) + + +class FakeRequest: + def __init__( + self, + *, + post_data: dict[str, object] | None = None, + match_info: dict[str, str] | None = None, + ) -> None: + self._post_data = post_data or {} + self.match_info = match_info or {} + + async def post(self) -> dict[str, object]: + return self._post_data + + +def test_attach_sound_routes_registers_live_handlers(tmp_path: Path): + routes = FakeRoutes() + bundled_dir = tmp_path / "bundled" + custom_dir = tmp_path / "custom" + legacy_custom_dir = tmp_path / "legacy" + bundled_dir.mkdir() + custom_dir.mkdir() + (bundled_dir / "ping-success.wav").write_bytes(b"bundled") + + attach_sound_routes( + routes=routes, + web_module=FakeWeb, + bundled_dir=bundled_dir, + custom_dir=custom_dir, + legacy_custom_dir=legacy_custom_dir, + ) + + assert ("GET", SOUND_CATALOG_ROUTE) in routes.handlers + assert ("GET", SOUND_FILE_ROUTE) in routes.handlers + assert ("POST", SOUND_UPLOAD_ROUTE) in routes.handlers + + list_handler = routes.handlers[("GET", SOUND_CATALOG_ROUTE)] + list_response = run(list_handler(FakeRequest())) + assert list_response.payload == { + "sounds": [{"name": "ping-success.wav", "source": "bundled"}] + } + + upload_handler = routes.handlers[("POST", SOUND_UPLOAD_ROUTE)] + upload_response = run( + upload_handler( + FakeRequest( + post_data={ + "file": FakeUploadField( + filename="custom.wav", + data=b"custom", + ) + } + ) + ) + ) + assert upload_response.status == 201 + assert upload_response.payload == { + "sounds": [ + {"name": "ping-success.wav", "source": "bundled"}, + {"name": "custom.wav", "source": "custom"}, + ] + } + + serve_handler = routes.handlers[("GET", SOUND_FILE_ROUTE)] + serve_response = run( + serve_handler( + FakeRequest( + match_info={ + "source": "custom", + "filename": "custom.wav", + } + ) + ) + ) + assert serve_response.path == custom_dir / "custom.wav" diff --git a/tests/python/test_sound_storage.py b/tests/python/test_sound_storage.py new file mode 100644 index 0000000..3ff7043 --- /dev/null +++ b/tests/python/test_sound_storage.py @@ -0,0 +1,55 @@ +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from comfyui_ping.sounds import ( + ALLOWED_EXTENSIONS, + is_allowed_upload, + list_available_sounds, +) + + +def test_list_sounds_returns_bundled_and_custom_entries(): + sounds = list_available_sounds( + bundled=["ping-success.wav"], + custom=["my.wav"], + ) + + assert sounds == [ + {"name": "ping-success.wav", "source": "bundled"}, + {"name": "my.wav", "source": "custom"}, + ] + + +def test_rejects_non_audio_extension(): + assert is_allowed_upload("not-a-sound.txt") is False + + +def test_accepts_expected_audio_extensions(): + assert ".wav" in ALLOWED_EXTENSIONS + assert is_allowed_upload("ping-success.wav") is True + assert is_allowed_upload("failure.ogg") is True + + +def test_bundled_sound_files_exist(): + assert (REPO_ROOT / "sounds" / "bundled" / "ping-success.wav").exists() + assert (REPO_ROOT / "sounds" / "bundled" / "ping-failure.wav").exists() + assert (REPO_ROOT / "sounds" / "bundled" / "ping-ringtone.wav").exists() + assert (REPO_ROOT / "sounds" / "bundled" / "notification-soft.wav").exists() + assert (REPO_ROOT / "sounds" / "bundled" / "beep-ping.wav").exists() + assert (REPO_ROOT / "sounds" / "bundled" / "harmonic-beep.wav").exists() + + +def test_catalog_preserves_duplicate_names_by_source(): + sounds = list_available_sounds( + bundled=["ping-success.wav"], + custom=["ping-success.wav"], + ) + + assert sounds == [ + {"name": "ping-success.wav", "source": "bundled"}, + {"name": "ping-success.wav", "source": "custom"}, + ] diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..9a66eb8 --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applySoundCatalogToSettings, + buildRenderedSoundOptions, + DEFAULT_FAILURE_SOUND_ID, + DEFAULT_SUCCESS_SOUND_ID, + PING_EVENT_NAME, + PING_EXTENSION, + PING_SETTINGS, + PING_SETTINGS_IDS, + syncRuntimeSettings, + refreshSoundCatalog, + tryUploadCustomSound, +} from "../src/index"; + +type FakeListener = () => void; + +class FakeElement { + public readonly children: FakeElement[] = []; + public readonly style: Record = {}; + public textContent = ""; + protected readonly listeners = new Map(); + + public constructor(public readonly tagName: string) {} + + public addEventListener(type: string, listener: FakeListener): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + public append(...nodes: FakeElement[]): void { + this.children.push(...nodes); + } + + public dispatchEvent(event: Event): boolean { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(); + } + return true; + } + + public querySelector(selector: string): FakeElement | null { + for (const child of this.children) { + if (child.tagName === selector) { + return child; + } + const nested = child.querySelector(selector); + if (nested) { + return nested; + } + } + return null; + } + + public replaceChildren(...nodes: FakeElement[]): void { + this.children.length = 0; + this.children.push(...nodes); + } +} + +class FakeOptionElement extends FakeElement { + public selected = false; + public value = ""; + + public constructor() { + super("option"); + } +} + +class FakeSelectElement extends FakeElement { + public selectedIndex = -1; + + public constructor() { + super("select"); + } + + public get options(): FakeOptionElement[] { + return this.children.filter( + (child): child is FakeOptionElement => child instanceof FakeOptionElement + ); + } + + public get value(): string { + return this.options.find((option) => option.selected)?.value ?? ""; + } + + public set value(nextValue: string) { + let matchedIndex = -1; + for (const [index, option] of this.options.entries()) { + option.selected = option.value === nextValue; + if (option.selected) { + matchedIndex = index; + } + } + + this.selectedIndex = matchedIndex; + } +} + +class FakeDocument { + public createElement(tagName: string): FakeElement { + if (tagName === "option") { + return new FakeOptionElement(); + } + if (tagName === "select") { + return new FakeSelectElement(); + } + return new FakeElement(tagName); + } +} + +describe("PING_SETTINGS", () => { + it("includes the expected notification settings", () => { + const settingIds = PING_SETTINGS.map((setting) => setting.id); + + expect(settingIds).toEqual( + expect.arrayContaining([ + PING_SETTINGS_IDS.GLOBAL_ENABLED, + PING_SETTINGS_IDS.NOTIFY_MODE, + PING_SETTINGS_IDS.SUCCESS_ENABLED, + PING_SETTINGS_IDS.FAILURE_ENABLED, + PING_SETTINGS_IDS.SUCCESS_SOUND, + PING_SETTINGS_IDS.FAILURE_SOUND, + PING_SETTINGS_IDS.UPLOAD_ACTION, + PING_SETTINGS_IDS.VOLUME, + ]) + ); + }); + + it("uses bundled defaults for success and failure sounds", () => { + const successSetting = PING_SETTINGS.find( + (setting) => setting.id === PING_SETTINGS_IDS.SUCCESS_SOUND + ); + const failureSetting = PING_SETTINGS.find( + (setting) => setting.id === PING_SETTINGS_IDS.FAILURE_SOUND + ); + + expect(successSetting?.defaultValue).toBe(DEFAULT_SUCCESS_SOUND_ID); + expect(failureSetting?.defaultValue).toBe(DEFAULT_FAILURE_SOUND_ID); + }); + + it("applies source-aware catalog options to both sound settings", () => { + applySoundCatalogToSettings({ + sounds: [ + { name: "ping-success.wav", source: "bundled" }, + { name: "uploaded.wav", source: "custom" }, + ], + }); + + const successSetting = PING_SETTINGS.find( + (setting) => setting.id === PING_SETTINGS_IDS.SUCCESS_SOUND + ); + const failureSetting = PING_SETTINGS.find( + (setting) => setting.id === PING_SETTINGS_IDS.FAILURE_SOUND + ); + + expect(successSetting?.options).toEqual([ + { text: "Bundled / ping-success.wav", value: "bundled:ping-success.wav" }, + { text: "Custom / uploaded.wav", value: "custom:uploaded.wav" }, + ]); + expect(failureSetting?.options).toEqual([ + { text: "Bundled / ping-success.wav", value: "bundled:ping-success.wav" }, + { text: "Custom / uploaded.wav", value: "custom:uploaded.wav" }, + ]); + }); + + it("preserves a persisted custom selection while the live catalog is still loading", () => { + expect( + buildRenderedSoundOptions( + [{ text: "Bundled / ping-success.wav", value: "bundled:ping-success.wav" }], + "custom:uploaded.wav" + ) + ).toEqual([ + { + text: "Custom / uploaded.wav (unavailable)", + value: "custom:uploaded.wav", + }, + { text: "Bundled / ping-success.wav", value: "bundled:ping-success.wav" }, + ]); + }); + + it("refreshes the sound catalog through fetchApi", async () => { + const fetchApi = vi.fn(async () => + new Response( + JSON.stringify({ + sounds: [{ name: "uploaded.wav", source: "custom" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + await expect( + refreshSoundCatalog({ + api: { + addEventListener: vi.fn(), + fetchApi, + }, + }) + ).resolves.toEqual({ + sounds: [{ name: "uploaded.wav", source: "custom" }], + }); + expect(fetchApi).toHaveBeenCalledWith("/comfyui-ping/sounds", undefined); + }); + + it("syncs runtime notification settings from current setting values", async () => { + const fetchApi = vi.fn( + async (_route: string, options?: RequestInit): Promise => + new Response(options?.body as BodyInit, { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect( + syncRuntimeSettings({ + api: { + addEventListener: vi.fn(), + fetchApi, + }, + ui: { + settings: { + getSettingValue: (id: string, defaultValue?: unknown) => { + if (id === PING_SETTINGS_IDS.GLOBAL_ENABLED) { + return false; + } + if (id === PING_SETTINGS_IDS.NOTIFY_MODE) { + return "every_prompt"; + } + if (id === PING_SETTINGS_IDS.SUCCESS_ENABLED) { + return true; + } + if (id === PING_SETTINGS_IDS.FAILURE_ENABLED) { + return false; + } + if (id === PING_SETTINGS_IDS.SUCCESS_SOUND) { + return "custom:uploaded.wav"; + } + if (id === PING_SETTINGS_IDS.FAILURE_SOUND) { + return "bundled:ping-failure.wav"; + } + if (id === PING_SETTINGS_IDS.VOLUME) { + return 0.35; + } + return defaultValue; + }, + }, + }, + }) + ).resolves.toEqual({ + enabled: false, + failure_enabled: false, + failure_sound: "bundled:ping-failure.wav", + notify_mode: "every_prompt", + success_enabled: true, + success_sound: "custom:uploaded.wav", + volume: 0.35, + }); + + expect(fetchApi).toHaveBeenCalledWith( + "/comfyui-ping/settings", + expect.objectContaining({ + method: "POST", + }) + ); + }); + + it("warns and leaves settings unchanged when a custom sound upload fails", async () => { + const warn = vi.fn(); + const fetchApi = vi.fn(async () => + new Response( + JSON.stringify({ + error: "Filename already exists in custom sounds", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + await expect( + tryUploadCustomSound( + { + api: { + addEventListener: vi.fn(), + fetchApi, + }, + }, + new File(["sound"], "uploaded.wav", { type: "audio/wav" }), + { warn } + ) + ).resolves.toBeNull(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Unable to upload custom sound") + ); + expect(fetchApi).toHaveBeenCalledWith( + "/comfyui-ping/sounds/upload", + expect.objectContaining({ + method: "POST", + }) + ); + }); + + it("syncs runtime settings when the custom success sound renderer changes", async () => { + let successSoundValue = DEFAULT_SUCCESS_SOUND_ID; + const fetchApi = vi.fn( + async (_route: string, options?: RequestInit): Promise => + new Response(options?.body as BodyInit, { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + vi.stubGlobal("app", { + api: { + addEventListener: vi.fn(), + fetchApi, + }, + ui: { + settings: { + getSettingValue: (id: string, defaultValue?: unknown) => { + if (id === PING_SETTINGS_IDS.SUCCESS_SOUND) { + return successSoundValue; + } + return defaultValue; + }, + settingsLookup: { + [PING_SETTINGS_IDS.SUCCESS_SOUND]: { + onChange: PING_SETTINGS.find( + (setting) => setting.id === PING_SETTINGS_IDS.SUCCESS_SOUND + )?.onChange, + }, + }, + }, + }, + }); + vi.stubGlobal("document", new FakeDocument()); + + const successSetting = PING_SETTINGS.find( + (setting) => setting.id === PING_SETTINGS_IDS.SUCCESS_SOUND + ); + + if (typeof successSetting?.type !== "function") { + throw new Error("Expected a renderer function for the success sound setting"); + } + + const rendered = successSetting.type( + "Success Sound", + (value: unknown) => { + successSoundValue = String(value); + }, + successSoundValue + ); + + const selectEl = rendered.querySelector("select") as HTMLSelectElement | null; + if (!selectEl) { + throw new Error("Expected the renderer to return a select element"); + } + + selectEl.value = "bundled:ping-failure.wav"; + selectEl.dispatchEvent(new Event("change")); + await Promise.resolve(); + + expect(fetchApi).toHaveBeenCalledWith( + "/comfyui-ping/settings", + expect.objectContaining({ + method: "POST", + }) + ); + + vi.unstubAllGlobals(); + }); + + it("previews the selected sound from the sound setting renderer", async () => { + class FakeAudio { + public static created: FakeAudio[] = []; + public currentTime = 0; + public readonly pause = vi.fn(); + public readonly play = vi.fn(async () => undefined); + public volume = 0; + + public constructor(public readonly src: string) { + FakeAudio.created.push(this); + } + } + + let successSoundValue = "custom:uploaded.wav"; + + vi.stubGlobal("app", { + ui: { + settings: { + getSettingValue: (id: string, defaultValue?: unknown) => { + if (id === PING_SETTINGS_IDS.SUCCESS_SOUND) { + return successSoundValue; + } + if (id === PING_SETTINGS_IDS.VOLUME) { + return 0.35; + } + return defaultValue; + }, + }, + }, + }); + vi.stubGlobal("Audio", FakeAudio); + vi.stubGlobal("document", new FakeDocument()); + + applySoundCatalogToSettings({ + sounds: [ + { name: "ping-success.wav", source: "bundled" }, + { name: "uploaded.wav", source: "custom" }, + ], + }); + + const successSetting = PING_SETTINGS.find( + (setting) => setting.id === PING_SETTINGS_IDS.SUCCESS_SOUND + ); + + if (typeof successSetting?.type !== "function") { + throw new Error("Expected a renderer function for the success sound setting"); + } + + const rendered = successSetting.type( + "Success Sound", + (value: unknown) => { + successSoundValue = String(value); + }, + successSoundValue + ); + + const playButton = rendered.querySelector("button"); + if (!playButton) { + throw new Error("Expected the renderer to return a play button"); + } + + playButton.dispatchEvent(new Event("click")); + await Promise.resolve(); + + expect(FakeAudio.created).toHaveLength(1); + expect(FakeAudio.created[0]?.src).toBe("/comfyui-ping/sounds/custom/uploaded.wav"); + expect(FakeAudio.created[0]?.volume).toBe(0.35); + expect(FakeAudio.created[0]?.play).toHaveBeenCalledTimes(1); + + vi.unstubAllGlobals(); + }); + + it("subscribes to ping notification events", async () => { + const addEventListener = vi.fn(); + + vi.stubGlobal("app", { + api: { + addEventListener, + fetchApi: vi.fn(async () => + new Response( + JSON.stringify({ + sounds: [{ name: "ping-success.wav", source: "bundled" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ), + }, + }); + + expect(PING_EVENT_NAME).toBe("comfyui-ping.notification"); + + await PING_EXTENSION.setup?.(); + + expect(addEventListener).toHaveBeenCalledWith( + PING_EVENT_NAME, + expect.any(Function) + ); + + vi.unstubAllGlobals(); + }); +}); diff --git a/tests/sound-resolution.test.ts b/tests/sound-resolution.test.ts new file mode 100644 index 0000000..cab5b62 --- /dev/null +++ b/tests/sound-resolution.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { resolveSoundSelection } from "../src/resolve-sound"; + +describe("resolveSoundSelection", () => { + it("prefers node override over global sound", () => { + expect( + resolveSoundSelection({ + status: "success", + nodeOverrides: { successSound: "bundled:node.wav" }, + globalSettings: { successSound: "bundled:global.wav" }, + }) + ).toBe("bundled:node.wav"); + }); + + it("falls back to the matching global sound", () => { + expect( + resolveSoundSelection({ + status: "failure", + nodeOverrides: {}, + globalSettings: { failureSound: "custom:failure.wav" }, + }) + ).toBe("custom:failure.wav"); + }); + + it("returns null when no matching sound is configured", () => { + expect( + resolveSoundSelection({ + status: "failure", + nodeOverrides: { successSound: "bundled:success.wav" }, + globalSettings: {}, + }) + ).toBeNull(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4e00dd5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "allowSyntheticDefaultImports": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts", "src", "tests"] +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..898d278 --- /dev/null +++ b/uv.lock @@ -0,0 +1,433 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + +[[package]] +name = "bump-my-version" +version = "1.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "questionary" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "tomlkit" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/11/0f73c652396f86197ea6d509c78e8c44c3483d9a86437ca53ce55edca8e8/bump_my_version-1.2.7.tar.gz", hash = "sha256:d915a10b41e0c9db5a2fa39bde9f45f92e1e4194242d819c9ceb9eca8831cd21", size = 1198071, upload-time = "2026-02-14T13:44:59.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/ed/ad1755f82cd5a0baafe342e7154696a93e57f04f86515402f14e5beceb36/bump_my_version-1.2.7-py3-none-any.whl", hash = "sha256:16f89360f979c0a8eb3249ebe3e13ae4f0cb5481d7bb58e12a9f66996922acfd", size = 60013, upload-time = "2026-02-14T13:44:58.318Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comfyui-ping" +version = "1.0.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "bump-my-version" }, + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "bump-my-version", specifier = ">=1.2.2" }, + { name = "pytest", specifier = ">=8.4.2" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/27/091e140ea834272188e63f8dd6faac1f5c687582b687197b3e0ec3c78ebf/rich_click-1.9.7.tar.gz", hash = "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", size = 74838, upload-time = "2026-01-31T04:29:27.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..d147a55 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from "vite"; +import path from "node:path"; + +// Plugin to insert the ComfyUI custom import at the beginning of the file +const insertComfyUICustomImport = () => { + return { + name: "insert-comfyui-custom-import", + transform(code: string) { + return `import { app } from "/scripts/app.js";\n${code}`; + }, + }; +}; + +export default defineConfig(() => ({ + plugins: [insertComfyUICustomImport()], + build: { + emptyOutDir: true, + rollupOptions: { + // Don't bundle ComfyUI scripts - they will be loaded from the ComfyUI server + external: ["/scripts/app.js", "/scripts/api.js"], + input: { + index: path.resolve(__dirname, "src/index.ts"), + }, + output: { + // Output to the dist/example_ext directory + dir: "dist", + entryFileNames: "[name].js", + chunkFileNames: "[name]-[hash].js", + assetFileNames: "[name][extname]", + }, + }, + }, +})); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..66cfc63 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + include: ["tests/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: [ + "src/index.ts", // Extension entry point not testable without ComfyUI + "src/layout/reroute-collapse.ts", // No fixtures with reroute nodes yet + ], + thresholds: { + lines: 70, + }, + }, + }, +});