commit f9223bf051d577efba6a9411b9330e9436033afb Author: pbanddev <202373785+PBandDev@users.noreply.github.com> Date: Thu Mar 19 13:24:54 2026 -0500 Initial commit 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 0000000..7effda6 Binary files /dev/null and b/sounds/bundled/beep-ping.wav differ diff --git a/sounds/bundled/harmonic-beep.wav b/sounds/bundled/harmonic-beep.wav new file mode 100644 index 0000000..235652a Binary files /dev/null and b/sounds/bundled/harmonic-beep.wav differ diff --git a/sounds/bundled/notification-soft.wav b/sounds/bundled/notification-soft.wav new file mode 100644 index 0000000..105ce77 Binary files /dev/null and b/sounds/bundled/notification-soft.wav differ diff --git a/sounds/bundled/ping-failure.wav b/sounds/bundled/ping-failure.wav new file mode 100644 index 0000000..be3f27a Binary files /dev/null and b/sounds/bundled/ping-failure.wav differ diff --git a/sounds/bundled/ping-ringtone.wav b/sounds/bundled/ping-ringtone.wav new file mode 100644 index 0000000..5d4ea7e Binary files /dev/null and b/sounds/bundled/ping-ringtone.wav differ diff --git a/sounds/bundled/ping-success.wav b/sounds/bundled/ping-success.wav new file mode 100644 index 0000000..ce23d72 Binary files /dev/null and b/sounds/bundled/ping-success.wav differ diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..cfac466 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,171 @@ +import { SOUND_SOURCES } from "./constants"; + +export type SoundSource = + (typeof SOUND_SOURCES)[keyof typeof SOUND_SOURCES]; + +export interface FetchApiClient { + fetchApi: (route: string, options?: RequestInit) => 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, + }, + }, + }, +});