diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..04c4a3f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install CPU dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --index-url https://download.pytorch.org/whl/cpu torch + python -m pip install Pillow aiohttp numpy scipy + - name: Run offline distribution tests + env: + NO_PROXY: "*" + run: python -m unittest discover -s tests -v + + javascript: + name: JavaScript syntax + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Check shipped JavaScript syntax + shell: bash + run: | + found=0 + while IFS= read -r -d '' file; do + node --check "$file" + found=1 + done < <(find web -type f \( -name '*.js' -o -name '*.mjs' \) -print0) + test "$found" -eq 1 diff --git a/.gitignore b/.gitignore index 3948fa7..9853012 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ __pycache__/ *.py[cod] .pytest_cache/ .env -.env.* +.env.* +.codex/ +.claude/ diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md new file mode 100644 index 0000000..60c133a --- /dev/null +++ b/AGENT-INSTALL.md @@ -0,0 +1,31 @@ +# Installation contract for AI agents + +Install MATRIX LAB NODES package version 0.2.0 as one ComfyUI custom-node pack. Treat the installed `MANIFEST.json` as the exact class inventory. + +## Hard boundaries + +- Resolve the intended ComfyUI root from the launcher or operator configuration; never guess from a common path. +- Do not overwrite, merge, delete, move, or replace an existing installation without explicit operator approval. +- Refuse an existing target folder and refuse enabled legacy packs that register overlapping class IDs. +- Do not change ComfyUI core or replace a working host Python, Torch, or CUDA environment. +- Do not read, print, copy, log, or commit credential values. +- Do not enable or queue the xAI **Generate Prompt** action, a paid provider operation, or a model workflow during installation verification. +- Do not claim success from files or imports alone. Verify discovery in the visible ComfyUI UI. + +## Procedure + +1. Resolve and report the exact ComfyUI root, its launcher, and the Python interpreter used by that launcher. +2. Stop ComfyUI through its normal controlled process. +3. Inspect `custom_nodes` for the target and for older `MATRIXLAB-Nodes` or `MATRIXLAB-UI-Nodes` installations. If any conflict exists, stop and report the exact folder names; do not modify them. +4. Obtain the exact operator-authorized commit or release archive. Record the commit SHA or SHA-256 before installation. +5. Place it in a new direct child folder `custom_nodes/matrix-lab-nodes`. Refuse archive traversal, an extra wrapper directory, symlink or junction surprises, or files outside that folder. +6. Verify that the root contains `__init__.py`, `MANIFEST.json`, `requirements.txt`, `_core/`, `nodes/`, and `web/`. Read `MANIFEST.json`; require `pack_id` `matrix-lab-nodes`, version `0.2.0`, fourteen unique class IDs, and six category groups. +7. Resolve the declared Python dependencies with the same interpreter that runs ComfyUI. Preserve an already working Torch/CUDA stack. If dependency resolution would replace Torch, CUDA packages, or another host-critical package, stop and present the proposed changes. +8. Restart ComfyUI through its normal launcher. Treat any pack import or frontend asset error as failure. +9. In the visible UI, search for `MATRIX RESOLUTION`, `MATRIX PHOTO FINISHER`, and `MATRIX AUTO PROMPTER`; compare all discovered MATRIX classes with `MANIFEST.json`. +10. Add `MATRIXLAB_Resolution`, choose `3:4` and `2K`, and verify 1536 by 2048. Do not queue any model, detector, sampler, or provider path. +11. Report the installed source/hash, target folder, manifest version and classes, dependency actions, visible discovery result, geometry result, and every blocker. Distinguish offline verification from unperformed live model/renderer acceptance. + +## Credential and private-data boundary + +The Prompt Director credential must be supplied only by the operator through the masked node control or server environment. Never inspect an existing provider key or test it during installation. Do not upload images, prompts, workflows, logs, or screenshots. Diagnostics must exclude credentials, local paths, private media, prompt text, and provider response bodies. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..92dd4fc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +User-visible changes are recorded here. This file describes package content; publication tags and release channels are separate facts. + +## 0.2.0 - development candidate - 2026-09-09 + +- Unified the surviving execution and UI nodes into one package with six stable category directories. +- Preserved the public class IDs listed in `NODES.md` and retained `MATRIXLAB_EasyCrop`. +- Replaced the retired finishing pair with `MATRIX_PhotoFinisher`, a local deterministic Torch node with `Clean Digital`, `Everyday Capture`, and `Low Light` creative profiles. +- Set new Photo Finisher defaults to `Everyday Capture`, mix `1`, texture `1`, detail `1`, contrast `0`, warmth `0`, saturation `1`, and seed `42`. +- Simplified the general and AI-influencer resolution nodes to two integer outputs and included `MATRIXLAB_AIInfluencerResolution2K4K` for current Krea 2 geometry selection. +- Kept Prompt Director provider generation behind an explicit paid action; ordinary graph execution returns saved text without a network request. +- Added public installation, migration, compatibility, node, license-notice, and agent-installation documentation. + +Full live acceptance of the current bytes across both ComfyUI renderers and all model-dependent paths remains pending. + +## 0.1.0 + +- Initial unified-package development metadata. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..65b6408 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,60 @@ +# Installation for people + +Install MATRIX LAB NODES package version 0.2.0 as one ComfyUI custom-node pack. + +## Before you begin + +- Use a working ComfyUI installation with Python 3.10 or newer. +- Back up important workflows and the current custom-node installation. +- Close ComfyUI before changing custom nodes. +- Check for folders that provide the older `MATRIXLAB-Nodes` or `MATRIXLAB-UI-Nodes` packs. They cannot remain enabled beside this unified package because class IDs overlap. +- Preserve the host's working Python, Torch, and CUDA versions. + +## Install from Git + +From the `custom_nodes` directory of the intended ComfyUI installation: + +```bash +git clone https://github.com/JsonMatrixLab/MATRIX-LAB-Nodes.git matrix-lab-nodes +cd matrix-lab-nodes +``` + +If the repository is private, authenticate through your normal Git credential flow. Do not put a token in the clone URL, command history, or repository files. + +Use the Python interpreter that launches this ComfyUI instance to install the declared dependencies. Review the command before running it if your environment manager might replace Torch: + +```bash +python -m pip install -r requirements.txt +``` + +For portable or embedded ComfyUI, replace `python` with that installation's bundled interpreter and adjust the relative path. + +## Install from an archive + +Extract the release archive to a new folder named `matrix-lab-nodes` directly under the intended ComfyUI `custom_nodes` directory. The package root must contain `__init__.py`, `MANIFEST.json`, `requirements.txt`, `_core/`, `nodes/`, and `web/`. Avoid an extra archive wrapper directory. + +## Restart and verify + +1. Start ComfyUI through its normal launcher and inspect startup output for import errors. +2. Open node search and find `MATRIX RESOLUTION`, `MATRIX PHOTO FINISHER`, and `MATRIX AUTO PROMPTER`. +3. Compare the installed classes with `MANIFEST.json`. The menu should retain six `MATRIX LAB` category groups. +4. Add `MATRIXLAB_Resolution`, select `3:4` and `2K`, and confirm outputs of 1536 by 2048 without queueing a model workflow. +5. Open a copy of an existing workflow and follow [docs/migration.md](docs/migration.md) if ComfyUI reports missing retired or incompatible classes. + +This verification proves discovery and the local geometry path only. It does not prove every model, detector, sampler, GPU, or frontend renderer. + +## External models and prompting + +Model weights are not bundled. Installing the pack does not acquire them, establish redistribution rights, or complete compatible inference-runtime setup. `MATRIX_SkinMask` and `MATRIX_EyeMask` fail when their registered assets or compatible runtimes are unavailable; do not bypass identity checks with a similarly named file. A successful package import is not proof that either mask node is ready to execute. Follow [the detector and segmentation setup guide](docs/detector-setup.md) for exact paths, hashes, source evidence, runtime boundaries, and unresolved license questions. + +`MATRIXLAB_PromptDirector` returns saved text during normal execution. Its explicit **Generate Prompt** control uses xAI and may incur charges. Configure credentials through its masked control or the documented server environment, never inside a workflow. Make the first provider call only after reviewing the selected model, reference images, request text, and cost boundary. + +## Update + +Back up workflows, record the installed commit or archive hash, and read [CHANGELOG.md](CHANGELOG.md) plus [docs/migration.md](docs/migration.md). Stop ComfyUI before updating. Do not merge a new archive over an existing tree; install the new version into a clean staging folder, verify its structure, then replace the disabled old folder using your normal recoverable file-management process. + +After restart, repeat node discovery and test copies of important workflows. Keep the previous package available as a disabled rollback until those checks pass. + +## Uninstall + +Stop ComfyUI, back up any files you need, and remove or move the single `matrix-lab-nodes` folder through your normal recoverable process. Restart ComfyUI and confirm its classes are absent. Removing the pack does not remove provider account data, external model weights, ComfyUI input/output images, or saved workflows. diff --git a/MANIFEST.json b/MANIFEST.json index 0eef321..01af5c2 100644 --- a/MANIFEST.json +++ b/MANIFEST.json @@ -21,9 +21,9 @@ "version": "1.0.0" }, { - "content_hash": "9bbf3d99167453c45d8473b8f1207865fcd9bba369d7066b71289cf41a21219d", + "content_hash": "dcaf1a3ae2fc7ac78413342300aea580751b42ca019414b5cbb3c205a43d1611", "id": "ui.appearance", - "version": "0.5.0" + "version": "0.5.1" }, { "content_hash": "fb98f796fea73106cfafb443d725f4db92bfacc3232b6fb00fd6807610a4ef12", @@ -36,9 +36,9 @@ "version": "0.2.3" }, { - "content_hash": "01840c9fa972d067a24c93f20cff73eaff08e18b833f54c1cb23c6faefe4b725", + "content_hash": "4fda8c8c1fbce41aac7b6c4670aa7efd87a60adcaf4604a6af808b20f0c9195a", "id": "mask.skin-region", - "version": "0.3.0" + "version": "0.3.1" }, { "content_hash": "7af6afab2008c90d1396302c3efd948ea6087369602db065a5918547f0d73d15", @@ -61,14 +61,9 @@ "version": "0.1.1" }, { - "content_hash": "b0e2bc157eca2086faf3d5b57bf7ae685490c4ec34a7a4cfb3d00925391b112d", - "id": "image.renoise", - "version": "0.2.2" - }, - { - "content_hash": "12c4b225fcff2a1028b361003f5c6b741aa1f3a109f86f8d23055f5237417432", - "id": "color.camera-look", - "version": "0.1.1" + "content_hash": "4e161007e22a23373d85333840fe9d8235cf8beb385633f775679840346613c7", + "id": "image.photo-finisher", + "version": "0.1.0" }, { "content_hash": "869da644f94fb10c804e477bb2bd8062ffdab3545855f52f56580dbf5ad4f47f", @@ -86,7 +81,7 @@ "version": "0.1.0" }, { - "content_hash": "5bb59338cab04e21642c98f1d7de92be6e90245531472960d16347ee245f0716", + "content_hash": "4e67bd73efebed6140e3531e5cf8a95f06d60d174f66afbbeb91e2c3c4e34e03", "id": "io.image-collection", "version": "0.2.1" }, @@ -113,8 +108,14 @@ { "id": "prompt.director", "version": "0.2.1" + }, + { + "content_hash": "a7cb4cc08e46e4a1603c4473221207e1db8b49ff959f9be6a0d312e5299d3648", + "id": "resolution.ai-influencer-2k4k", + "version": "1.0.0" } ], + "builder_sha256": "c2716a4a31e0f380576ebccccf3844353a379e0c7fd58af85f665084c1c3f87f", "categories": { "MATRIXLAB_AIInfluencerResolution": "MATRIX LAB/Resolution & Layout", "MATRIXLAB_EasyCrop": "MATRIX LAB/Image Processing", @@ -122,58 +123,69 @@ "MATRIXLAB_PromptDirector": "MATRIX LAB/Prompting", "MATRIXLAB_Resolution": "MATRIX LAB/Resolution & Layout", "MATRIXSpectralSampler": "MATRIX LAB/Sampling & Detail", - "MATRIX_CameraLook": "MATRIX LAB/Image Processing", "MATRIX_CropTailPaste": "MATRIX LAB/Sampling & Detail", "MATRIX_EyeMask": "MATRIX LAB/Masks & Detection", "MATRIX_LatentTail": "MATRIX LAB/Sampling & Detail", "MATRIX_OutputStage": "MATRIX LAB/Image Processing", - "MATRIX_Renoise": "MATRIX LAB/Image Processing", + "MATRIX_PhotoFinisher": "MATRIX LAB/Image Processing", "MATRIX_SaveClean": "MATRIX LAB/Input & Output", - "MATRIX_SkinMask": "MATRIX LAB/Masks & Detection" + "MATRIX_SkinMask": "MATRIX LAB/Masks & Detection", + "MATRIXLAB_AIInfluencerResolution2K4K": "MATRIX LAB/Resolution & Layout" }, + "compiler_sha256": "b1ff75cd7cefd07caaf7fc224d2b4518d7a65954c89e3bdee770d867a37e1e07", "declaration_hashes": { "MATRIXLAB_AIInfluencerResolution": "8f6737741244227d88ed8a411548fd788fe311f776ca9aba68b67f34bf9695d9", "MATRIXLAB_EasyCrop": "e3e4d55649ba8fd8d1d5fb2abc526af8ca98b3b7a5b62665965eed080b0f3c2a", "MATRIXLAB_ImageBatchLoader": "bd6b56231c6d2842fb044ff7c5a2c775ca2043837a5879eff9a397569b153319", - "MATRIXLAB_PromptDirector": "4084dad0bdfe8b49430049245f4f8cc041a409d217b9e55394754f705249c0bc", + "MATRIXLAB_PromptDirector": "bbebce94b066143c8879239fa0003fc934207bebed40c6352d9d9bba882e54e4", "MATRIXLAB_Resolution": "38fea06eda599cb3c78a029b8e0478b3625bc1cd281019caec6cd9612a3ff521", "MATRIXSpectralSampler": "767c07ee23b36895169d5039756f8f425ef0aa0e70ca639d616b5af75c829306", - "MATRIX_CameraLook": "8c2132f6975da7120cb9fd51e883ba475651a9f2b31ad2365c90944f482f2541", "MATRIX_CropTailPaste": "7c0a5e925c00a9a08d445e9b8c9315909f17084d9279c37f3841cbce9c2670c7", "MATRIX_EyeMask": "c507e7a4ca5c6b959ab503b4229ec929856576567bc28a82b06e8f5cabebd7ef", "MATRIX_LatentTail": "8677264027158b709269b0ffe22c4ce7a9b81862f78d5d9057f964a0f362f83e", "MATRIX_OutputStage": "6ec9c264ab82dfd9b8c0e8640007a26db25818b3bf7a934db34e7403ba441bbc", - "MATRIX_Renoise": "78806798d0a4dcd256098631d76eb761898abf11d5a2d15a7530ebb2738ea713", + "MATRIX_PhotoFinisher": "eadad3bc1712edec30fa52fff0a929e4807a77b17b60fd5e51b4c4403d7bf809", "MATRIX_SaveClean": "3aaeea9d3b5b246e909df9a21ae9f2883bf251c055dcc9f8e89714beae65a3ce", - "MATRIX_SkinMask": "e17b320fdc4ab7535fb6f017578e43b87e6f8d90740adfba2aeabe1a377521f2" + "MATRIX_SkinMask": "e17b320fdc4ab7535fb6f017578e43b87e6f8d90740adfba2aeabe1a377521f2", + "MATRIXLAB_AIInfluencerResolution2K4K": "589e6e5dc7c7eb7ee22943b13729bf2012350baec8c4b35b0320badcbfd6b22c" }, "display_name": "MATRIX LAB NODES", "frontend_entrypoints": [ - "web/ai_influencer_resolution.ebb32f136a6a4294.js", - "web/easy_crop.3cd2d4db9e4387a9.js", - "web/halo_execution.41a5414872870fdf.js", - "web/image_collection.8677fd9cc9c550ba.js", - "web/prompt_director.b4c0409b1a3bd876.js", - "web/resolution_control_deck.355f138d9de085d8.js" + "web/ai_influencer_resolution.bd473fd081a9ee32.js", + "web/ai_influencer_resolution_2k4k.5a6f0a9d0be56983.js", + "web/easy_crop.e448d80a597cbf32.js", + "web/halo_execution.9c05a384226d4180.js", + "web/image_collection.57c26c1112198635.js", + "web/prompt_director.47d51c1218485744.js", + "web/resolution_control_deck.83ec2d2da93e1ef9.js" ], "nodes": [ "MATRIXLAB_AIInfluencerResolution", + "MATRIXLAB_AIInfluencerResolution2K4K", "MATRIXLAB_EasyCrop", "MATRIXLAB_ImageBatchLoader", "MATRIXLAB_PromptDirector", "MATRIXLAB_Resolution", "MATRIXSpectralSampler", - "MATRIX_CameraLook", "MATRIX_CropTailPaste", "MATRIX_EyeMask", "MATRIX_LatentTail", "MATRIX_OutputStage", - "MATRIX_Renoise", + "MATRIX_PhotoFinisher", "MATRIX_SaveClean", "MATRIX_SkinMask" ], "pack_id": "matrix-lab-nodes", - "runtime_asset_registry_sha256": "ba2964519d538b65fafbbe2fbad5a9e7d16940334f49fcc517ddab041c59a5c4", + "runtime_asset_registry_sha256": "ab1955cfe5e8d05b832a4f63cae121d9afd6f74d3a9048b0107cf634659cada8", "status": "development", - "version": "0.1.0" + "version": "0.2.0", + "candidate_provenance": { + "strategy": "current-canonical-base-plus-additive-factory-node", + "base_builder_sha256": "c2716a4a31e0f380576ebccccf3844353a379e0c7fd58af85f665084c1c3f87f", + "compiler_sha256": "b1ff75cd7cefd07caaf7fc224d2b4518d7a65954c89e3bdee770d867a37e1e07", + "builder_sha256": "7df54e744e3aff1b58fd9699f5e1564e3f0c71d8370fe8b50b07aedf74dbeca9", + "existing_node_implementation_policy": "fresh-canonical-base-build", + "runtime_registry_policy": "owned-by-canonical-base-builder", + "runtime_asset_proof_sha256": "917902e05bb4e0758e6ba9b264075f89edbc0b2b39580fb2cb55d6e9c76945f8" + } } diff --git a/NODES.md b/NODES.md new file mode 100644 index 0000000..54f5312 --- /dev/null +++ b/NODES.md @@ -0,0 +1,22 @@ +# Node reference + +Package version 0.2.0 contains the fourteen classes below in six `MATRIX LAB` groups. The installed `MANIFEST.json` is authoritative for a particular artifact. + +| Display name | Class ID | Inputs | Outputs | Guide | +| --- | --- | --- | --- | --- | +| MATRIX METADATA KILLER | `MATRIX_SaveClean` | `images`; save options | output node | [Guide](docs/nodes/matrix-save-clean.md) | +| MATRIX IMAGE BATCH LOADER | `MATRIXLAB_ImageBatchLoader` | `collection` | `images`, `masks` | [Guide](docs/nodes/image-batch-loader.md) | +| MATRIX RESOLUTION | `MATRIXLAB_Resolution` | ratio, tier, custom dimensions | `width`, `height` | [Guide](docs/nodes/resolution.md) | +| MATRIX AI INFLUENCER RESOLUTION | `MATRIXLAB_AIInfluencerResolution` | ratio, tier | `width`, `height` | [Guide](docs/nodes/ai-influencer-resolution.md) | +| MATRIX AI INFLUENCER RESOLUTION 2K/4K | `MATRIXLAB_AIInfluencerResolution2K4K` | ratio, 2K/4K tier | `width`, `height` | [Guide](docs/nodes/ai-influencer-resolution-2k4k.md) | +| MATRIX SPECTRAL SAMPLER | `MATRIXSpectralSampler` | sampler and spectral controls | `sampler` | [Guide](docs/nodes/spectral-sampler.md) | +| MATRIX LATENT TAIL | `MATRIX_LatentTail` | model/noise/conditioning/latent; tail controls | `latent` | [Guide](docs/nodes/latent-tail.md) | +| MATRIX CROP TAIL PASTE | `MATRIX_CropTailPaste` | image/mask/model/noise/conditioning/VAE; crop controls | `image` | [Guide](docs/nodes/crop-tail-paste.md) | +| MATRIX SKIN MASK | `MATRIX_SkinMask` | image; part and mask controls | `mask`, `preview` | [Guide](docs/nodes/skin-mask.md) | +| MATRIX EYE MASK | `MATRIX_EyeMask` | image; detector and mask controls | `mask`, `masks`, `bboxes`, `preview` | [Guide](docs/nodes/eye-mask.md) | +| MATRIX PHOTO FINISHER | `MATRIX_PhotoFinisher` | image; optional mask and finish controls | `image` | [Guide](docs/nodes/photo-finisher.md) | +| MATRIX EASY CROP | `MATRIXLAB_EasyCrop` | uploaded image and crop geometry | `image`, `mask` | [Guide](docs/nodes/easy-crop.md) | +| MATRIX OUTPUT STAGE | `MATRIX_OutputStage` | image/model/noise/conditioning/VAE/target geometry; output controls | `image`, `info` | [Guide](docs/nodes/output-stage.md) | +| MATRIX AUTO PROMPTER | `MATRIXLAB_PromptDirector` | saved prompt fields; optional images | `prompt` | [Guide](docs/nodes/auto-prompter.md) | + +`MATRIXLAB_AIInfluencerResolution2K4K` does not replace the base AI Influencer Resolution class. diff --git a/README.md b/README.md index 88f42d9..4af38e3 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,60 @@ # MATRIX LAB NODES -A unified ComfyUI custom-node pack with 14 nodes in six functional groups. Development version 0.1.0. +MATRIX LAB NODES (package version 0.2.0) is a unified ComfyUI custom-node pack for image input, resolution planning, sampling, masks, finishing, output, and assisted prompting. -| Group | Nodes | +> [!IMPORTANT] +> Version 0.2.0 is a development candidate. Offline package and ABI checks do not establish full live acceptance across ComfyUI renderers, GPU/model combinations, or production workflows. + +The package keeps fourteen classes in six stable menu groups under `MATRIX LAB`. The exact classes in a downloaded build are also listed in its `MANIFEST.json`. + +## What it does + +- Loads and crops ordered input images without silently resizing mixed dimensions. +- Calculates model-independent 1K, 2K, and 4K pixel geometry. +- Supplies sampling and masked detail helpers for ComfyUI workflows. +- Builds skin and eye masks through separately supplied, identity-checked model assets. +- Applies deterministic local photo finishing and saves clean JPEG or PNG output. +- Returns a saved prompt locally; an explicit **Generate Prompt** action can call xAI after credential setup and paid-use approval. + +## Included groups + +| Group | Included nodes | | --- | --- | | [Input & Output](nodes/input_output/) | MATRIX METADATA KILLER, MATRIX IMAGE BATCH LOADER | -| [Resolution & Layout](nodes/resolution_layout/) | MATRIX RESOLUTION, MATRIX AI INFLUENCER RESOLUTION | +| [Resolution & Layout](nodes/resolution_layout/) | MATRIX RESOLUTION, MATRIX AI INFLUENCER RESOLUTION, MATRIX AI INFLUENCER RESOLUTION 2K/4K | | [Sampling & Detail](nodes/sampling_detail/) | MATRIX SPECTRAL SAMPLER, MATRIX LATENT TAIL, MATRIX CROP TAIL PASTE | | [Masks & Detection](nodes/masks_detection/) | MATRIX SKIN MASK, MATRIX EYE MASK | -| [Image Processing](nodes/image_processing/) | MATRIX RENOISE, MATRIX CAMERA LOOK, MATRIX EASY CROP, MATRIX OUTPUT STAGE | +| [Image Processing](nodes/image_processing/) | MATRIX PHOTO FINISHER, MATRIX EASY CROP, MATRIX OUTPUT STAGE | | [Prompting](nodes/prompting/) | MATRIX AUTO PROMPTER | -## Installation +See [NODES.md](NODES.md) for the class inventory and [the node guides](docs/nodes/) for inputs, outputs, and practical boundaries. + +## Install -Install this repository under your ComfyUI custom_nodes directory and install requirements.txt using ComfyUI's Python environment, then restart ComfyUI. +- For a normal installation, follow [INSTALL.md](INSTALL.md). +- For installation by a zero-context coding agent, provide the repository and require [AGENT-INSTALL.md](AGENT-INSTALL.md). -This pack preserves class IDs from MATRIXLAB-Nodes and MATRIXLAB-UI-Nodes. Disable those two older packs before enabling this one. Installing them together produces duplicate class registrations. Keep a backup of your workflows and previous installation before switching. +Do not install this pack beside the older `MATRIXLAB-Nodes` or `MATRIXLAB-UI-Nodes` packs. Shared class IDs would register twice. Back up workflows and the previous installation, disable the old packs, install this package as one folder under `custom_nodes`, restart ComfyUI, and confirm the expected classes in node search before opening an important workflow. -The nodes use ComfyUI's existing torch installation; keep a torch build appropriate for your hardware. Detection and segmentation nodes also require their external model/provider dependencies. Eye Mask validates the model identities listed in _core/runtime-assets.json; model weights are not bundled. Provider-backed prompting requires a configured credential and an explicit generation action. +## Safe start -## Resolution +1. Read the installed `MANIFEST.json` and confirm the class inventory. +2. Add `MATRIXLAB_Resolution` and verify its two integer outputs. +3. Load a copy of one workflow and resolve missing or changed classes using [the migration guide](docs/migration.md). +4. Keep **Generate Prompt** unused until an xAI credential, model choice, and paid request are intentionally approved. -Both Resolution controls output only width and height. 1K, 2K and 4K mean an exact longer edge of 1024, 2048 and 4096 pixels. The shorter edge follows the selected aspect ratio, rounded to the nearest integer. These are pixel dimensions, not a guarantee that a model supports direct generation at that size. +For a local image-processing check, load [the Photo Finisher example](examples/README.md), choose your own PNG or JPEG, and preview the result. The example contains no photograph, model weights, or provider call. -General Resolution offers eight aspect presets plus Custom, with exact custom dimensions from 1 to 16384 pixels, Swap and Reset. AI Influencer Resolution offers 1:1, 9:16 and 3:4. +## Dependencies and data -Old general Resolution workflows preserve their previous pixel dimensions as Custom where safe. Old connected AI Influencer nodes used a six-output render/delivery/sampler contract and require manual replacement; they are marked as legacy missing nodes instead of silently rewiring incompatible outputs. Review generation size, delivery size and sampler settings separately. +The pack uses the Python/Torch environment supplied by ComfyUI and declares Pillow, aiohttp, NumPy, SciPy, and Torch. Preserve a working host Torch/CUDA installation when resolving dependencies. Detection nodes need external model assets and compatible inference runtimes; model acquisition, licensing, and compatible runtime setup are not completed by installing this repository. Model weights are not bundled. Their hashes and logical identities are checked by the runtime registry, but this package does not grant rights to those files. The mask nodes are not ready to run merely because the pack imports. -Resolution Plan, Resolution 2K and Resolution 4K Progressive are not included. Workflows using those classes require migration. +Most nodes run locally. `MATRIXLAB_PromptDirector` performs no network request during ordinary graph execution. Its explicit **Generate Prompt** action sends the selected local reference images and prompt fields to xAI. Credentials stay outside serialized workflows. Provider use may incur charges and remains subject to the provider's terms. -## Compatibility and status +## Release boundary -The UI supports Classic and Nodes 2.0 through the shared MATRIX presentation. Offline geometry, serialization, lifecycle and package checks do not constitute full GPU/model or production acceptance. This unified distribution is a development build; confirm your intended workflow before production use. +The repository is the source distribution channel. A GitHub Release, Comfy Registry entry, or ComfyUI Manager listing is a separate publication and must not be inferred from repository availability. Compatibility evidence and current limits are documented in [docs/compatibility.md](docs/compatibility.md). -## License +## License and security -MATRIX LAB code remains subject to LICENSE. Bundled Cascadia Mono has its own license in web/assets/CascadiaMono-LICENSE.txt. External model weights and provider services retain their respective licenses and terms. +MATRIX LAB code remains subject to [LICENSE](LICENSE). Bundled Cascadia Mono is covered separately in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md). Read [SECURITY.md](SECURITY.md) before sharing diagnostics that may contain private workflow or provider data. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a00339c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security + +Do not include credentials, private prompts, workflows, local paths, provider response bodies, or private media in public reports. + +`MATRIXLAB_PromptDirector` keeps provider credentials outside serialized workflows. Ordinary node execution returns saved text and makes no provider request. Only the explicit **Generate Prompt** action may send selected reference images and prompt fields to xAI. Review that request before authorizing provider use. + +When reporting a vulnerability, use the repository's Security tab if private reporting is available. Otherwise, open a minimal issue containing no secrets, private data, or working exploit and request a private contact route. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..0455044 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,13 @@ +# Third-party notices + +## Cascadia Mono + +This distribution includes `web/assets/CascadiaMono.woff2`. The font is licensed under the SIL Open Font License, Version 1.1. The bundled license text is available at `web/assets/CascadiaMono-LICENSE.txt` and controls use of that font. + +## Items not bundled + +Python dependencies are installed separately into the user's ComfyUI environment. External detector, segmentation, and other model weights are not included. + +The optional mask runtimes can involve ONNX Runtime, Ultralytics, and Segment Anything. Segment Anything publishes Apache-2.0 terms. Ultralytics publishes AGPL-3.0 and Enterprise options; which terms apply to this proprietary pack and a particular deployment has not been established here. Consult current upstream terms before installing, integrating, or distributing these packages. + +The four expected detector and segmentation weights, their source links, hashes, and available license evidence are listed in [docs/detector-setup.md](docs/detector-setup.md). The Eye Mask detector weight has no established license in the retained evidence; the human-parts weight's underlying model license and the U-2-Net conversion lineage also remain unresolved. Review each source and intended use independently. Provider services and other downloaded assets have their own terms. This notice does not add rights to third-party code, weights, or services. diff --git a/__init__.py b/__init__.py index f201b6b..f0e3494 100644 --- a/__init__.py +++ b/__init__.py @@ -6,12 +6,11 @@ from .nodes.sampling_detail.matrix_croptailpaste import NODE_CLASS_MAPPINGS as _c4, NODE_DISPLAY_NAME_MAPPINGS as _d4 from .nodes.input_output.matrix_saveclean import NODE_CLASS_MAPPINGS as _c5, NODE_DISPLAY_NAME_MAPPINGS as _d5 from .nodes.image_processing.matrix_outputstage import NODE_CLASS_MAPPINGS as _c6, NODE_DISPLAY_NAME_MAPPINGS as _d6 -from .nodes.image_processing.matrix_renoise import NODE_CLASS_MAPPINGS as _c7, NODE_DISPLAY_NAME_MAPPINGS as _d7 -from .nodes.image_processing.matrix_cameralook import NODE_CLASS_MAPPINGS as _c8, NODE_DISPLAY_NAME_MAPPINGS as _d8 -from .nodes.resolution_layout.matrixlab_resolution import NODE_CLASS_MAPPINGS as _c9, NODE_DISPLAY_NAME_MAPPINGS as _d9 -from .nodes.resolution_layout.matrixlab_aiinfluencerresolution import NODE_CLASS_MAPPINGS as _c10, NODE_DISPLAY_NAME_MAPPINGS as _d10 -from .nodes.image_processing.matrixlab_easycrop import NODE_CLASS_MAPPINGS as _c11, NODE_DISPLAY_NAME_MAPPINGS as _d11 -from .nodes.input_output.matrixlab_imagebatchloader import NODE_CLASS_MAPPINGS as _c12, NODE_DISPLAY_NAME_MAPPINGS as _d12 +from .nodes.image_processing.matrix_photofinisher import NODE_CLASS_MAPPINGS as _c7, NODE_DISPLAY_NAME_MAPPINGS as _d7 +from .nodes.resolution_layout.matrixlab_resolution import NODE_CLASS_MAPPINGS as _c8, NODE_DISPLAY_NAME_MAPPINGS as _d8 +from .nodes.resolution_layout.matrixlab_aiinfluencerresolution import NODE_CLASS_MAPPINGS as _c9, NODE_DISPLAY_NAME_MAPPINGS as _d9 +from .nodes.image_processing.matrixlab_easycrop import NODE_CLASS_MAPPINGS as _c10, NODE_DISPLAY_NAME_MAPPINGS as _d10 +from .nodes.input_output.matrixlab_imagebatchloader import NODE_CLASS_MAPPINGS as _c11, NODE_DISPLAY_NAME_MAPPINGS as _d11 NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} @@ -39,8 +38,10 @@ NODE_DISPLAY_NAME_MAPPINGS.update(_d10) NODE_CLASS_MAPPINGS.update(_c11) NODE_DISPLAY_NAME_MAPPINGS.update(_d11) -NODE_CLASS_MAPPINGS.update(_c12) -NODE_DISPLAY_NAME_MAPPINGS.update(_d12) + +from .nodes.resolution_layout.matrixlab_aiinfluencerresolution2k4k import NODE_CLASS_MAPPINGS as _resolution_2k4k_c, NODE_DISPLAY_NAME_MAPPINGS as _resolution_2k4k_d +NODE_CLASS_MAPPINGS.update(_resolution_2k4k_c) +NODE_DISPLAY_NAME_MAPPINGS.update(_resolution_2k4k_d) try: import folder_paths as _matrix_folder_paths diff --git a/_core/color_camera_look/__init__.py b/_core/color_camera_look/__init__.py deleted file mode 100644 index 81203a2..0000000 --- a/_core/color_camera_look/__init__.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Deterministic, batch-safe camera and color grading for ComfyUI IMAGE tensors.""" - -from __future__ import annotations - -from dataclasses import dataclass -from io import BytesIO -import math - -import numpy as np -from PIL import Image, ImageFilter -import torch -import torch.nn.functional as torch_functional - - -__all__ = [ - "CameraLookError", - "CameraLookProcessingError", - "CameraLookValidationError", - "camera_look", - "execute_utility_operation", -] - - -class CameraLookError(RuntimeError): - """Base error for the camera-look operation.""" - - -class CameraLookValidationError(CameraLookError, ValueError): - """The tensor or a runtime widget value violates the operation contract.""" - - -class CameraLookProcessingError(CameraLookError): - """An image-processing dependency returned an invalid result.""" - - -@dataclass(frozen=True) -class _Parameters: - chromatic_aberration: float - demosaic_pixel_blur: bool - noise_strength: float - kernel_motion_blur: int - jpeg_compression: int - seed: int - - -def _check_interrupted() -> None: - """Use ComfyUI's engine check when the block is loaded inside ComfyUI.""" - try: - from comfy.model_management import throw_exception_if_processing_interrupted - except ImportError: - return - throw_exception_if_processing_interrupted() - - -def _require_bool(name: str, value: object) -> bool: - if type(value) is not bool: - raise CameraLookValidationError(f"{name} must be an exact boolean") - return value - - -def _require_float(name: str, value: object, minimum: float, maximum: float) -> float: - if type(value) not in (float, int) or isinstance(value, bool): - raise CameraLookValidationError(f"{name} must be a finite real number") - converted = float(value) - if not math.isfinite(converted) or not minimum <= converted <= maximum: - raise CameraLookValidationError(f"{name} must be in [{minimum}, {maximum}]") - return converted - - -def _require_int(name: str, value: object, minimum: int, maximum: int) -> int: - if type(value) is not int or not minimum <= value <= maximum: - raise CameraLookValidationError(f"{name} must be an exact integer in [{minimum}, {maximum}]") - return value - - -def _validate_image(image: object, *, check_samples: bool = True) -> torch.Tensor: - if not isinstance(image, torch.Tensor): - raise CameraLookValidationError("image must be a torch tensor") - if image.ndim != 4: - raise CameraLookValidationError("image must have rank 4 in [B,H,W,C] order") - _, height, width, channels = image.shape - if height < 1 or width < 1: - raise CameraLookValidationError("image spatial dimensions must both be positive") - if channels not in (3, 4): - raise CameraLookValidationError("image channel count must be exactly 3 or 4") - if not image.is_floating_point(): - raise CameraLookValidationError("image must use a floating dtype") - # Float8 does not implement isfinite on all pinned torch builds. Empty and - # disabled paths deliberately avoid even this conversion/allocation. - if check_samples and image.numel() and not bool( - torch.isfinite(image.to(dtype=torch.float32)).all().item() - ): - raise CameraLookValidationError("image contains a non-finite sample") - return image - - -def _derive_frame_seed(seed: int, frame_index: int) -> int: - """SplitMix64 gives each frame a stable stream without global RNG state.""" - mask = (1 << 64) - 1 - value = (seed + frame_index + 0x9E3779B97F4A7C15) & mask - value = ((value ^ (value >> 30)) * 0xBF58476D1CE4E5B9) & mask - value = ((value ^ (value >> 27)) * 0x94D049BB133111EB) & mask - return (value ^ (value >> 31)) & mask - - -def _apply_noise(rgb: torch.Tensor, strength: float, seed: int) -> torch.Tensor: - if strength == 0.0: - return rgb - generator = torch.Generator(device="cpu") - generator.manual_seed(seed) - amount = (strength / 5.0) ** 2 - samples = rgb.to(torch.float32) - photons = samples * (80.0 / 255.0) - shot = (torch.poisson(photons, generator=generator) - photons) * (2.0 * amount) - read = torch.randn(samples.shape, generator=generator, dtype=torch.float32) * (4.0 * amount) - return (samples + shot + read).round().clamp_(0, 255).to(torch.uint8) - - -def _apply_demosaic(rgb: torch.Tensor) -> torch.Tensor: - image = Image.fromarray(rgb.numpy(), mode="RGB") - filtered = image.filter(ImageFilter.GaussianBlur(radius=0.5)) - return torch.from_numpy(np.asarray(filtered, dtype=np.uint8).copy()) - - -def _apply_aberration(rgb: torch.Tensor, amount: float) -> torch.Tensor: - shift = round(amount * rgb.shape[1] / 500.0) - if shift == 0: - return rgb - shifted = rgb.clone() - shifted[:, :, 0] = 0 - shifted[:, :, 2] = 0 - if shift < rgb.shape[1]: - shifted[:, shift:, 0] = rgb[:, :-shift, 0] - shifted[:, :-shift, 2] = rgb[:, shift:, 2] - return shifted - - -def _apply_motion_blur(rgb: torch.Tensor, requested_kernel: int) -> torch.Tensor: - if requested_kernel == 1: - return rgb - kernel_size = requested_kernel if requested_kernel % 2 else requested_kernel + 1 - kernel = torch.full((1, 1, kernel_size), 1.0 / kernel_size, dtype=torch.float32) - result = torch.empty_like(rgb) - padding = kernel_size // 2 - for row_index in range(rgb.shape[0]): - _check_interrupted() - row = rgb[row_index].to(torch.float32).transpose(0, 1).unsqueeze(1) - blurred = torch_functional.conv1d(row, kernel, padding=padding) - result[row_index] = blurred.squeeze(1).transpose(0, 1).round().clamp_(0, 255).to(torch.uint8) - return result - - -def _jpeg_round_trip(rgb: torch.Tensor, quality: int) -> torch.Tensor: - height, width, _ = rgb.shape - buffer = BytesIO() - try: - Image.fromarray(rgb.numpy(), mode="RGB").save(buffer, format="JPEG", quality=quality) - buffer.seek(0) - with Image.open(buffer) as decoded: - if decoded.mode != "RGB" or decoded.size != (width, height): - raise CameraLookProcessingError( - f"JPEG decoder returned mode={decoded.mode!r}, size={decoded.size!r}" - ) - decoded.load() - array = np.asarray(decoded, dtype=np.uint8).copy() - except CameraLookProcessingError: - raise - except Exception as error: - raise CameraLookProcessingError("JPEG round trip failed") from error - if array.shape != (height, width, 3): - raise CameraLookProcessingError(f"JPEG decoder returned malformed shape {array.shape!r}") - return torch.from_numpy(array) - - -def _process_frame(frame: torch.Tensor, parameters: _Parameters, frame_index: int) -> torch.Tensor: - _check_interrupted() - frame_float = frame.to(dtype=torch.float32) - alpha = frame_float[..., 3].clone() if frame_float.shape[-1] == 4 else None - rgb = (frame_float[..., :3].clamp(0, 1) * 255.0).round().to(torch.uint8) - - rgb = _apply_noise(rgb, parameters.noise_strength, _derive_frame_seed(parameters.seed, frame_index)) - _check_interrupted() - if parameters.demosaic_pixel_blur: - rgb = _apply_demosaic(rgb) - _check_interrupted() - rgb = _apply_aberration(rgb, parameters.chromatic_aberration) - _check_interrupted() - rgb = _apply_motion_blur(rgb, parameters.kernel_motion_blur) - _check_interrupted() - rgb = _jpeg_round_trip(rgb, parameters.jpeg_compression) - _check_interrupted() - - output_rgb = rgb.to(torch.float32).div_(255.0) - if alpha is None: - return output_rgb.contiguous() - return torch.cat((output_rgb, alpha.unsqueeze(-1)), dim=-1).contiguous() - - -def camera_look( - image: torch.Tensor, - *, - enabled: bool, - chromatic_aberration: float, - demosaic_pixel_blur: bool, - noise_strength: float, - kernel_motion_blur: int, - jpeg_compression: int, - look_seed: int, -) -> torch.Tensor: - """Apply the contracted camera look to every BHWC frame in order.""" - enabled = _require_bool("enabled", enabled) - demosaic_pixel_blur = _require_bool("demosaic_pixel_blur", demosaic_pixel_blur) - parameters = _Parameters( - chromatic_aberration=_require_float("chromatic_aberration", chromatic_aberration, 0.0, 10.0), - demosaic_pixel_blur=demosaic_pixel_blur, - noise_strength=_require_float("noise_strength", noise_strength, 0.0, 5.0), - kernel_motion_blur=_require_int("kernel_motion_blur", kernel_motion_blur, 1, 51), - jpeg_compression=_require_int("jpeg_compression", jpeg_compression, 85, 100), - seed=_require_int("look_seed", look_seed, 0, (1 << 64) - 1), - ) - image = _validate_image(image, check_samples=enabled) - if not enabled or image.shape[0] == 0: - return image - - output = torch.empty(image.shape, dtype=torch.float32, device=image.device) - for frame_index in range(image.shape[0]): - _check_interrupted() - frame = image[frame_index].detach().to(device="cpu", dtype=torch.float32) - processed = _process_frame(frame, parameters, frame_index) - output[frame_index].copy_(processed) - return output.contiguous() - - -def execute_utility_operation(item): - """Factory seam: execute the named camera-look contract without positional migration.""" - if not isinstance(item, dict) or "image" not in item: - raise CameraLookValidationError("color.camera-look requires an input mapping with 'image'") - return ( - camera_look( - item["image"], - enabled=item.get("enabled", True), - chromatic_aberration=item.get("chromatic_aberration", 0.0), - demosaic_pixel_blur=item.get("demosaic_pixel_blur", True), - noise_strength=item.get("noise_strength", 0.0), - kernel_motion_blur=item.get("kernel_motion_blur", 1), - jpeg_compression=item.get("jpeg_compression", 98), - # Deprecated 0.6.x dict-path alias; generated widgets use look_seed. - look_seed=item.get("look_seed", item.get("seed", 0)), - ), - ) diff --git a/_core/image_photo_finisher/__init__.py b/_core/image_photo_finisher/__init__.py new file mode 100644 index 0000000..4ba547f --- /dev/null +++ b/_core/image_photo_finisher/__init__.py @@ -0,0 +1,286 @@ +"""Deterministic, mask-aware photographic finishing for ComfyUI IMAGE tensors.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math + +import torch +import torch.nn.functional as F + + +@dataclass(frozen=True, slots=True) +class _Profile: + contrast: float + warmth: float + saturation: float + detail: float + luma_texture: float + chroma_texture: float + luma_sigma: float + chroma_sigma: float + highlight_threshold: float + + +# Display-referred starting points. These are creative profiles, not measured camera/ISO claims. +PROFILES = { + "Clean Digital": _Profile(0.025, 0.000, 1.00, 0.12, 0.0020, 0.0006, 0.35, 1.20, 0.94), + "Everyday Capture": _Profile(0.045, 0.012, 0.98, 0.08, 0.0045, 0.0015, 0.45, 1.50, 0.88), + "Low Light": _Profile(-0.010, 0.018, 0.94, -0.04, 0.0080, 0.0040, 0.70, 2.20, 0.72), +} + +# The coordinate field consumes exactly 32 seed bits; do not expose colliding higher values. +_MAX_SEED = (1 << 32) - 1 +_CORE_TILE = 512 +_MAX_HALO = 8 + + +class PhotoFinisherValidationError(ValueError): + """The caller violated the image.photo-finisher contract.""" + + +class PhotoFinisherAllocationError(RuntimeError): + """A bounded validation or work tile could not be allocated.""" + + +def _supported_dtypes() -> tuple[torch.dtype, ...]: + return (torch.float16, torch.float32, torch.bfloat16) + + +def _require_real(name: str, value: object, minimum: float, maximum: float) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise PhotoFinisherValidationError(f"{name} must be a finite real number in {minimum}..{maximum}") + converted = float(value) + if not math.isfinite(converted) or not minimum <= converted <= maximum: + raise PhotoFinisherValidationError(f"{name} must be a finite real number in {minimum}..{maximum}") + return converted + + +def _validate_controls(profile, mix, texture, detail, contrast, warmth, saturation, seed): + if type(profile) is not str or profile not in PROFILES: + raise PhotoFinisherValidationError(f"profile must be one of {tuple(PROFILES)}") + if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= _MAX_SEED: + raise PhotoFinisherValidationError(f"seed must be an integer in 0..{_MAX_SEED}") + return ( + PROFILES[profile], + _require_real("mix", mix, 0.0, 1.0), + _require_real("texture", texture, 0.0, 2.0), + _require_real("detail", detail, -1.0, 1.0), + _require_real("contrast", contrast, -1.0, 1.0), + _require_real("warmth", warmth, -1.0, 1.0), + _require_real("saturation", saturation, 0.0, 2.0), + ) + + +def _validate_structure(image: object, mask: object) -> tuple[torch.Tensor, torch.Tensor | None]: + if not isinstance(image, torch.Tensor) or image.ndim != 4: + raise PhotoFinisherValidationError("IMAGE must be a Torch tensor in BHWC layout") + if any(size < 1 for size in image.shape) or image.shape[-1] not in (3, 4): + raise PhotoFinisherValidationError("IMAGE must be non-empty BHWC RGB or RGBA") + if image.dtype not in _supported_dtypes(): + raise PhotoFinisherValidationError("IMAGE dtype must be fp16, fp32, or bf16") + if mask is None: + return image, None + if not isinstance(mask, torch.Tensor) or mask.ndim != 3: + raise PhotoFinisherValidationError("MASK must be a Torch tensor in BHW layout") + if not mask.is_floating_point(): + raise PhotoFinisherValidationError("MASK must use a floating dtype") + if tuple(mask.shape[1:]) != tuple(image.shape[1:3]): + raise PhotoFinisherValidationError("MASK height and width must exactly match IMAGE") + if mask.shape[0] not in (1, image.shape[0]): + raise PhotoFinisherValidationError("MASK batch must be 1 or equal the IMAGE batch") + return image, mask + + +def _check_interruption() -> None: + try: + from comfy.model_management import throw_exception_if_processing_interrupted + except ImportError: + return + throw_exception_if_processing_interrupted() + + +def _tile_bounds(height: int, width: int, halo: int): + for y0 in range(0, height, _CORE_TILE): + y1 = min(y0 + _CORE_TILE, height) + for x0 in range(0, width, _CORE_TILE): + x1 = min(x0 + _CORE_TILE, width) + yield ( + y0, y1, x0, x1, + max(0, y0 - halo), min(height, y1 + halo), + max(0, x0 - halo), min(width, x1 + halo), + ) + + +def _validate_values(image: torch.Tensor, mask: torch.Tensor | None) -> None: + try: + height, width = image.shape[1:3] + for frame in range(image.shape[0]): + for y0, y1, x0, x1, *_ in _tile_bounds(height, width, 0): + _check_interruption() + tile = image[frame, y0:y1, x0:x1].float() + if not bool(torch.isfinite(tile).all()) or bool((tile < 0).any()) or bool((tile > 1).any()): + raise PhotoFinisherValidationError("IMAGE values must be finite and in [0, 1]") + if mask is not None: + for frame in range(mask.shape[0]): + for y0, y1, x0, x1, *_ in _tile_bounds(height, width, 0): + _check_interruption() + tile = mask[frame, y0:y1, x0:x1].float() + if not bool(torch.isfinite(tile).all()) or bool((tile < 0).any()) or bool((tile > 1).any()): + raise PhotoFinisherValidationError("MASK values must be finite and in [0, 1]") + except PhotoFinisherValidationError: + raise + except (torch.cuda.OutOfMemoryError, MemoryError) as error: + raise PhotoFinisherAllocationError("image.photo-finisher could not allocate a bounded validation tile") from error + except RuntimeError as error: + message = str(error).lower() + if ("out of memory" in message or "can't allocate memory" in message + or "cannot allocate memory" in message or "defaultcpuallocator" in message + or "not enough memory" in message): + raise PhotoFinisherAllocationError("image.photo-finisher could not allocate a bounded validation tile") from error + raise PhotoFinisherValidationError(f"IMAGE or MASK values could not be validated: {error}") from error + + +def _coordinate_noise(height, width, *, channels, device, y0, x0, frame, seed, stream): + """A stateless uniform field keyed by pixel coordinate, frame, stream, and uint64 seed.""" + mask32 = 0xFFFFFFFF + y = torch.arange(y0, y0 + height, dtype=torch.int64, device=device).reshape(height, 1, 1) + x = torch.arange(x0, x0 + width, dtype=torch.int64, device=device).reshape(1, width, 1) + channel = torch.arange(channels, dtype=torch.int64, device=device).reshape(1, 1, channels) + salt = ((seed & mask32) ^ (((seed >> 32) * 0x9E3779B1) & mask32) + ^ ((frame * 0x85EBCA77) & mask32) ^ ((stream * 0xC2B2AE3D) & mask32)) + hashed = ((x * 0x27D4EB2F) ^ (y * 0x165667B1) ^ (channel * 0x7FEB352D) ^ salt) & mask32 + hashed = hashed ^ (hashed >> 16) + hashed = (hashed * 0x45D9F3B) & mask32 + hashed = hashed ^ (hashed >> 16) + hashed = (hashed * 0x45D9F3B) & mask32 + hashed = hashed ^ (hashed >> 16) + return hashed.to(torch.float32).mul_(2.0 / 4294967296.0).sub_(1.0) + + +def _gaussian_blur(field: torch.Tensor, sigma: float) -> torch.Tensor: + if sigma <= 0: + return field + radius = min(math.ceil(3.0 * sigma), _MAX_HALO) + positions = torch.arange(-radius, radius + 1, dtype=torch.float32, device=field.device) + kernel = torch.exp(-positions.square() / (2.0 * sigma * sigma)) + kernel = kernel / kernel.sum() + channels = field.shape[-1] + nchw = field.permute(2, 0, 1).unsqueeze(0) + horizontal = kernel.reshape(1, 1, 1, -1).expand(channels, 1, 1, -1) + vertical = kernel.reshape(1, 1, -1, 1).expand(channels, 1, -1, 1) + nchw = F.conv2d(F.pad(nchw, (radius, radius, 0, 0), mode="replicate"), horizontal, groups=channels) + nchw = F.conv2d(F.pad(nchw, (0, 0, radius, radius), mode="replicate"), vertical, groups=channels) + return nchw.squeeze(0).permute(1, 2, 0) + + +def _tone_and_color(rgb, profile, *, contrast, warmth, saturation): + contrast_scale = 1.0 + profile.contrast + contrast * 0.25 + toned = (rgb - 0.5) * contrast_scale + 0.5 + luminance = toned[..., 0:1] * 0.2126 + toned[..., 1:2] * 0.7152 + toned[..., 2:3] * 0.0722 + warmth_amount = profile.warmth + warmth * 0.04 + midtone = (4.0 * luminance * (1.0 - luminance)).clamp(0.0, 1.0) + bias = toned.new_tensor((1.0, 0.20, -1.0)).reshape(1, 1, 3) + toned = (toned + bias * warmth_amount * midtone).clamp(0.0, 1.0) + # Saturation owns the final chroma amplitude, including a true grayscale endpoint. + luminance = toned[..., 0:1] * 0.2126 + toned[..., 1:2] * 0.7152 + toned[..., 2:3] * 0.0722 + saturation_scale = profile.saturation * saturation + return (luminance + (toned - luminance) * saturation_scale).clamp(0.0, 1.0) + + +def _finish_rgb(rgb, profile, *, texture, detail, contrast, warmth, saturation, + frame, seed, y0, x0): + toned = _tone_and_color(rgb, profile, contrast=contrast, warmth=warmth, saturation=saturation) + detail_amount = profile.detail + detail * 0.35 + if detail_amount: + toned = (toned + (toned - _gaussian_blur(toned, 0.8)) * detail_amount).clamp(0.0, 1.0) + if texture: + luma = _coordinate_noise(rgb.shape[0], rgb.shape[1], channels=1, device=rgb.device, + y0=y0, x0=x0, frame=frame, seed=seed, stream=0) + chroma = _coordinate_noise(rgb.shape[0], rgb.shape[1], channels=3, device=rgb.device, + y0=y0, x0=x0, frame=frame, seed=seed, stream=1) + luma = _gaussian_blur(luma, profile.luma_sigma) * (profile.luma_texture * texture) + chroma = _gaussian_blur(chroma, profile.chroma_sigma) * (profile.chroma_texture * texture * saturation) + luminance = toned[..., 0:1] * 0.2126 + toned[..., 1:2] * 0.7152 + toned[..., 2:3] * 0.0722 + fade = ((1.0 - luminance) / (1.0 - profile.highlight_threshold)).clamp(0.0, 1.0) + toned = (toned + (luma.expand(-1, -1, 3) + chroma) * fade).clamp(0.0, 1.0) + return toned + + +def photo_finish(image: torch.Tensor, mask: torch.Tensor | None = None, *, + profile: str = "Everyday Capture", mix: float = 1.0, + texture: float = 1.0, detail: float = 1.0, contrast: float = 0.0, + warmth: float = 0.0, saturation: float = 1.0, seed: int = 42) -> torch.Tensor: + """Finish a display-referred BHWC image without resizing or encoding it.""" + image, mask = _validate_structure(image, mask) + profile_values, mix, texture, detail, contrast, warmth, saturation = _validate_controls( + profile, mix, texture, detail, contrast, warmth, saturation, seed + ) + _validate_values(image, mask) + if mix == 0.0 or (mask is not None and not bool(torch.count_nonzero(mask))): + return image + + halo = max(math.ceil(3.0 * 0.8), math.ceil(3.0 * profile_values.luma_sigma), + math.ceil(3.0 * profile_values.chroma_sigma)) + halo = min(halo, _MAX_HALO) + try: + result = torch.empty_like(image) + batch, height, width, _ = image.shape + for frame in range(batch): + _check_interruption() + mask_frame = None if mask is None else mask[0 if mask.shape[0] == 1 else frame] + for y0, y1, x0, x1, ey0, ey1, ex0, ex1 in _tile_bounds(height, width, halo): + _check_interruption() + expanded = image[frame, ey0:ey1, ex0:ex1, :3].float() + finished = _finish_rgb( + expanded, profile_values, texture=texture, detail=detail, + contrast=contrast, warmth=warmth, saturation=saturation, + frame=frame, seed=seed, y0=ey0, x0=ex0, + ) + ly0, ly1, lx0, lx1 = y0 - ey0, y1 - ey0, x0 - ex0, x1 - ex0 + source = image[frame, y0:y1, x0:x1, :3] + source_float = source.float() + weight = mix + if mask_frame is not None: + mask_tile = mask_frame[y0:y1, x0:x1].to(device=image.device, dtype=torch.float32) + weight = mask_tile.unsqueeze(-1) * mix + blended = torch.lerp(source_float, finished[ly0:ly1, lx0:lx1], weight).clamp(0, 1) + converted = blended.to(image.dtype) + if mask_frame is not None: + converted = torch.where((mask_tile == 0).unsqueeze(-1), source, converted) + result[frame, y0:y1, x0:x1, :3] = converted + if image.shape[-1] == 4: + result[frame, y0:y1, x0:x1, 3] = image[frame, y0:y1, x0:x1, 3] + except (PhotoFinisherValidationError, InterruptedError): + raise + except (torch.cuda.OutOfMemoryError, MemoryError) as error: + raise PhotoFinisherAllocationError("image.photo-finisher could not allocate a bounded work tile; no partial output returned") from error + except RuntimeError as error: + message = str(error).lower() + if ("out of memory" in message or "can't allocate memory" in message + or "cannot allocate memory" in message or "defaultcpuallocator" in message + or "not enough memory" in message): + raise PhotoFinisherAllocationError("image.photo-finisher could not allocate a bounded work tile; no partial output returned") from error + raise + return result + + +def execute_utility_operation(item): + if not isinstance(item, dict) or "image" not in item: + raise PhotoFinisherValidationError("image.photo-finisher requires an input mapping with 'image'") + allowed = {"image", "mask", "profile", "mix", "texture", "detail", "contrast", "warmth", "saturation", "seed"} + if set(item) - allowed: + raise PhotoFinisherValidationError("image.photo-finisher received unsupported input fields") + return (photo_finish( + item["image"], item.get("mask"), profile=item.get("profile", "Everyday Capture"), + mix=item.get("mix", 1.0), texture=item.get("texture", 1.0), + detail=item.get("detail", 1.0), contrast=item.get("contrast", 0.0), + warmth=item.get("warmth", 0.0), saturation=item.get("saturation", 1.0), + seed=item.get("seed", 42), + ),) + + +__all__ = [ + "PROFILES", "PhotoFinisherAllocationError", "PhotoFinisherValidationError", + "execute_utility_operation", "photo_finish", +] diff --git a/_core/image_renoise/__init__.py b/_core/image_renoise/__init__.py deleted file mode 100644 index 41ad8a1..0000000 --- a/_core/image_renoise/__init__.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Deterministic, bounded-memory sensor grain for ComfyUI IMAGE tensors.""" - -from __future__ import annotations - -import math - -import torch -import torch.nn.functional as F - - -ISO_PRESETS = { - "ISO 0 (Off)": (0.000, 0.000, 0.00, 0.0, 0.00), - "ISO 50": (0.004, 0.002, 0.35, 1.2, 0.98), - "ISO 100 (Clean)": (0.008, 0.005, 0.40, 1.5, 0.95), - "ISO 200": (0.012, 0.008, 0.45, 1.8, 0.90), - "ISO 400": (0.018, 0.012, 0.50, 2.0, 0.85), - "ISO 800": (0.025, 0.016, 0.55, 2.5, 0.78), - "ISO 1600": (0.035, 0.025, 0.65, 3.0, 0.70), - "Night Mode": (0.025, 0.035, 1.20, 4.0, 0.50), -} - -_MAX_SEED = 18446744073709551615 -_CORE_TILE = 1024 -_MAX_HALO = 12 - - -class RenoiseValidationError(ValueError): - """The caller violated the image.renoise public contract.""" - - -class RenoiseAllocationError(RuntimeError): - """A bounded work tile could not be allocated.""" - - -def _supported_dtypes() -> tuple[torch.dtype, ...]: - dtypes = [torch.float32, torch.bfloat16] - for name in ( - "float8_e4m3fn", - "float8_e4m3fnuz", - "float8_e5m2", - "float8_e5m2fnuz", - ): - dtype = getattr(torch, name, None) - if dtype is not None: - dtypes.append(dtype) - return tuple(dtypes) - - -def _validate_structure(image: torch.Tensor) -> None: - if not isinstance(image, torch.Tensor): - raise RenoiseValidationError("IMAGE must be a Torch tensor in BHWC layout") - if image.ndim != 4: - raise RenoiseValidationError("IMAGE must have rank 4 in BHWC layout") - if any(size < 1 for size in image.shape): - raise RenoiseValidationError("IMAGE batch, height, width, and channel dimensions must be non-empty") - if image.shape[-1] not in (3, 4): - raise RenoiseValidationError("IMAGE channel dimension must be exactly RGB or RGBA") - if image.dtype not in _supported_dtypes(): - raise RenoiseValidationError( - f"IMAGE dtype {image.dtype} on device {image.device} is unsupported; expected fp32, bf16, or fp8" - ) - - -def _validate_seed(grain_seed: int) -> None: - if ( - isinstance(grain_seed, bool) - or not isinstance(grain_seed, int) - or not 0 <= grain_seed <= _MAX_SEED - ): - raise RenoiseValidationError( - f"grain_seed must be an integer in the inclusive range 0..{_MAX_SEED}" - ) - - -def _validate_strength(strength: float) -> float: - if isinstance(strength, bool) or not isinstance(strength, (int, float)): - raise RenoiseValidationError("strength must be a finite real number in the inclusive range 0.0..2.0") - value = float(strength) - if not math.isfinite(value) or not 0.0 <= value <= 2.0: - raise RenoiseValidationError("strength must be a finite real number in the inclusive range 0.0..2.0") - return value - - -def _check_interruption() -> None: - try: - from comfy.model_management import throw_exception_if_processing_interrupted - except ImportError: - return - throw_exception_if_processing_interrupted() - - -def _tile_bounds(height: int, width: int, halo: int): - for y0 in range(0, height, _CORE_TILE): - y1 = min(y0 + _CORE_TILE, height) - for x0 in range(0, width, _CORE_TILE): - x1 = min(x0 + _CORE_TILE, width) - ey0 = max(0, y0 - halo) - ey1 = min(height, y1 + halo) - ex0 = max(0, x0 - halo) - ex1 = min(width, x1 + halo) - yield y0, y1, x0, x1, ey0, ey1, ex0, ex1 - - -def _validate_values(image: torch.Tensor) -> None: - height, width = image.shape[1:3] - try: - for frame in range(image.shape[0]): - for y0, y1, x0, x1, *_ in _tile_bounds(height, width, 0): - _check_interruption() - tile = image[frame, y0:y1, x0:x1].float() - if not bool(torch.isfinite(tile).all()): - raise RenoiseValidationError("IMAGE values must all be finite") - if bool((tile < 0.0).any()) or bool((tile > 1.0).any()): - raise RenoiseValidationError("IMAGE values must be in the inclusive range [0, 1]") - except RenoiseValidationError: - raise - except torch.cuda.OutOfMemoryError as error: - raise RenoiseAllocationError( - f"image.renoise could not allocate a bounded validation tile on {image.device}; no partial output returned" - ) from error - except RuntimeError as error: - if "out of memory" in str(error).lower(): - raise RenoiseAllocationError( - f"image.renoise could not allocate a bounded validation tile on {image.device}; no partial output returned" - ) from error - raise RenoiseValidationError( - f"IMAGE dtype {image.dtype} cannot be validated on device {image.device}: {error}" - ) from error - - -def _coordinate_noise( - height: int, - width: int, - *, - channels: int, - device: torch.device, - y0: int, - x0: int, - frame: int, - seed: int, - stream: int, - offset: torch.Tensor, -) -> torch.Tensor: - """Generate a stateless local random field, stable across work-tile boundaries. - - Integer hash (lowbias32-style avalanche) over (x, y, channel, frame, stream, seed): exact on every - backend, zero-mean uniform in [-1, 1). The earlier float32 ``frac(sin(...))`` hash aliased at - image coordinates above about 1000 px (regular stripes with a period of about seven rows and a - negative mean), which the pod evidence of 2026-09-03 exposed at 1536x2048. - """ - mask32 = 0xFFFFFFFF - y = torch.arange(y0, y0 + height, dtype=torch.int64, device=device).reshape(height, 1, 1) - x = torch.arange(x0, x0 + width, dtype=torch.int64, device=device).reshape(1, width, 1) - channel = torch.arange(channels, dtype=torch.int64, device=device).reshape(1, 1, channels) - seed_low = seed & mask32 - seed_high = (seed >> 32) & mask32 - offset_bits = int(float(offset.item()) * 4294967296.0) & mask32 - salt = ( - (int(frame) * 0x27D4EB2F) - ^ (int(stream) * 0x165667B1) - ^ seed_low - ^ ((seed_high * 0x9E3779B1) & mask32) - ^ offset_bits - ) & mask32 - h = ((x * 0x9E3779B1) ^ (y * 0x85EBCA77) ^ (channel * 0xC2B2AE3D) ^ salt) & mask32 - h = h ^ (h >> 16) - h = (h * 0x7FEB352D) & mask32 - h = h ^ (h >> 15) - h = (h * 0x846CA68B) & mask32 - h = h ^ (h >> 16) - return h.to(torch.float32).mul_(1.0 / 4294967296.0).mul_(2.0).sub_(1.0) - - -def _gaussian_blur(field: torch.Tensor, sigma: float) -> torch.Tensor: - if sigma <= 0.0: - return field - radius = min(int(math.ceil(3.0 * sigma)), _MAX_HALO) - positions = torch.arange(-radius, radius + 1, dtype=torch.float32, device=field.device) - kernel = torch.exp(-(positions.square()) / (2.0 * sigma * sigma)) - kernel = kernel / kernel.sum() - channels = field.shape[-1] - nchw = field.permute(2, 0, 1).unsqueeze(0) - horizontal = kernel.reshape(1, 1, 1, -1).expand(channels, 1, 1, -1) - vertical = kernel.reshape(1, 1, -1, 1).expand(channels, 1, -1, 1) - nchw = F.conv2d(F.pad(nchw, (radius, radius, 0, 0), mode="replicate"), horizontal, groups=channels) - nchw = F.conv2d(F.pad(nchw, (0, 0, radius, radius), mode="replicate"), vertical, groups=channels) - return nchw.squeeze(0).permute(1, 2, 0) - - -def _highlight_mask(rgb: torch.Tensor, threshold: float) -> torch.Tensor: - luminance = rgb[..., 0:1] * 0.2126 + rgb[..., 1:2] * 0.7152 + rgb[..., 2:3] * 0.0722 - fade = ((1.0 - luminance) / (1.0 - threshold)).clamp(0.0, 1.0) - return torch.where(luminance <= threshold, torch.ones_like(fade), fade) - - -def _renoise( - image: torch.Tensor, - seed: int, - coefficients: tuple[float, float, float, float, float], - strength: float, -) -> torch.Tensor: - luma_amplitude, chroma_amplitude, luma_sigma, chroma_sigma, threshold = coefficients - luma_amplitude *= strength - chroma_amplitude *= strength - halo = min(max(math.ceil(3.0 * luma_sigma), math.ceil(3.0 * chroma_sigma)), _MAX_HALO) - batch, height, width, _ = image.shape - result = torch.empty_like(image) - generator = torch.Generator(device=image.device) - generator.manual_seed(seed) - stream_offsets = torch.rand(2, dtype=torch.float32, device=image.device, generator=generator) - for frame in range(batch): - _check_interruption() - for y0, y1, x0, x1, ey0, ey1, ex0, ex1 in _tile_bounds(height, width, halo): - _check_interruption() - expanded_height = ey1 - ey0 - expanded_width = ex1 - ex0 - luma = _coordinate_noise( - expanded_height, - expanded_width, - channels=1, - device=image.device, - y0=ey0, - x0=ex0, - frame=frame, - seed=seed, - stream=0, - offset=stream_offsets[0], - ) - luma = _gaussian_blur(luma, luma_sigma).mul_(luma_amplitude) - _check_interruption() - chroma = _coordinate_noise( - expanded_height, - expanded_width, - channels=3, - device=image.device, - y0=ey0, - x0=ex0, - frame=frame, - seed=seed, - stream=1, - offset=stream_offsets[1], - ) - chroma = _gaussian_blur(chroma, chroma_sigma).mul_(chroma_amplitude) - _check_interruption() - local_y0, local_y1 = y0 - ey0, y1 - ey0 - local_x0, local_x1 = x0 - ex0, x1 - ex0 - noise = luma[local_y0:local_y1, local_x0:local_x1].expand(-1, -1, 3) - noise = noise + chroma[local_y0:local_y1, local_x0:local_x1] - rgb = image[frame, y0:y1, x0:x1, :3].float() - mask = _highlight_mask(rgb, threshold) - processed = (rgb + noise * mask).clamp_(0.0, 1.0).to(image.dtype) - result[frame, y0:y1, x0:x1, :3] = processed - if image.shape[-1] == 4: - result[frame, y0:y1, x0:x1, 3] = image[frame, y0:y1, x0:x1, 3] - return result - - -def renoise_image( - image: torch.Tensor, - grain_seed: int = 0, - iso_preset: str = "ISO 400", - strength: float = 1.0, -) -> torch.Tensor: - """Apply the selected deterministic sensor-grain preset to a BHWC IMAGE tensor.""" - _validate_structure(image) - _validate_seed(grain_seed) - strength = _validate_strength(strength) - if iso_preset not in ISO_PRESETS: - raise RenoiseValidationError( - f"iso_preset must be one of {tuple(ISO_PRESETS)}; received {iso_preset!r}" - ) - _validate_values(image) - if iso_preset == "ISO 0 (Off)" or strength == 0.0: - return image - try: - return _renoise(image, grain_seed, ISO_PRESETS[iso_preset], strength) - except (RenoiseValidationError, InterruptedError): - raise - except torch.cuda.OutOfMemoryError as error: - raise RenoiseAllocationError( - f"image.renoise could not allocate a bounded work tile on {image.device}; no partial output returned" - ) from error - except RuntimeError as error: - if "out of memory" in str(error).lower(): - raise RenoiseAllocationError( - f"image.renoise could not allocate a bounded work tile on {image.device}; no partial output returned" - ) from error - raise - - -def execute_utility_operation(item): - """Factory seam: one declared input mapping to one IMAGE output.""" - if not isinstance(item, dict) or "image" not in item: - raise RenoiseValidationError("image.renoise requires an input mapping with 'image'") - return ( - renoise_image( - item["image"], - # Deprecated 0.6.x dict-path alias; generated widgets use grain_seed. - grain_seed=item.get("grain_seed", item.get("seed", 0)), - iso_preset=item.get("iso_preset", "ISO 400"), - strength=item.get("strength", 1.0), - ), - ) - - -__all__ = [ - "ISO_PRESETS", - "RenoiseAllocationError", - "RenoiseValidationError", - "execute_utility_operation", - "renoise_image", -] diff --git a/_core/mask_skin_region/__init__.py b/_core/mask_skin_region/__init__.py index 424a82a..d8afb4c 100644 --- a/_core/mask_skin_region/__init__.py +++ b/_core/mask_skin_region/__init__.py @@ -275,12 +275,17 @@ def execute_utility_operation(item: dict[str, Any]) -> tuple[torch.Tensor, torch raise SkinMaskValidationError("mask.skin-region requires an input mapping") if "image" not in item: raise SkinMaskValidationError("missing skin mask input: image") + image = _validate_image(item["image"]) + toggles, feather, edge_radius, expand, use_gate = _validate_widgets(item) + if not any(toggles.values()): + output = torch.zeros(image.shape[:3], device=image.device, dtype=torch.float32) + for index in range(image.shape[0]): + _LOG.info("frame %d coverage=%.6f", index, 0.0) + return output, output[..., None].expand(-1, -1, -1, 3).clone() if _SEGMENT_PARTS is None or _SEGMENT_PERSON is None: raise SkinMaskValidationError( "mask.skin-region runtime segmenters are not configured by the pack bootstrap" ) - image = _validate_image(item["image"]) - toggles, feather, edge_radius, expand, use_gate = _validate_widgets(item) masks: list[torch.Tensor] = [] for index, frame in enumerate(image): soft = _parts_mask(frame, toggles, _SEGMENT_PARTS) diff --git a/_core/prompt_director/__init__.py b/_core/prompt_director/__init__.py index e907ec5..ad28939 100644 --- a/_core/prompt_director/__init__.py +++ b/_core/prompt_director/__init__.py @@ -25,7 +25,7 @@ def INPUT_TYPES(cls): RETURN_TYPES = ("STRING",) RETURN_NAMES = ("prompt",) FUNCTION = "execute" - CATEGORY = "MATRIX LAB UI NODES/Prompting" + CATEGORY = "MATRIX LAB/Prompting" DESCRIPTION = "Explicitly generate once, edit the saved prompt, then reuse it for free." def check_lazy_status(self, **_inputs): diff --git a/_core/prompt_director/provider.json b/_core/prompt_director/provider.json index 4071dd5..1542f5e 100644 --- a/_core/prompt_director/provider.json +++ b/_core/prompt_director/provider.json @@ -1,6 +1,5 @@ { "fetched": "2026-09-07T16:49:38+02:00", - "generator": "nodes/factory/source/tools/fetch_routes.py", "lifecycle": { "completion": "POST {base_url}/v1/chat/completions", "date": "2026-07-29", diff --git a/_core/prompt_director/server_routes.py b/_core/prompt_director/server_routes.py index deb4cbd..58759e2 100644 --- a/_core/prompt_director/server_routes.py +++ b/_core/prompt_director/server_routes.py @@ -23,8 +23,15 @@ from ..auth_provider_key import ProviderKeyStore from ..io_image_collection import MAX_IMAGES, input_digest, load_ordered_records, parse_collection_state from ..spend_tokens import TokenLedger, TokenPrices, TokenUsage -from ..text_refusal import finish_completion -from ..errors_taxonomy import failure_from_http +from ..text_refusal import TextRefusal, finish_completion +from ..errors_taxonomy import ( + AuthError, + IndeterminateSubmitError, + ProviderFailure, + TimeoutOrInterruptedError, + TransientTransportOrServerError, + failure_from_http, +) from .credential_sessions import CredentialSessionError, CredentialSessions @@ -101,6 +108,33 @@ class PromptDirectorError(RuntimeError): pass +class CredentialConnectError(CredentialSessionError): + def __init__(self, kind: str, message: str, *, status: int): + super().__init__(message) + self.kind = kind + self.status = status + + +def _submission_failure(exc: BaseException, phase: str) -> tuple[str, str]: + """Return durable state/category without persisting provider or secret detail.""" + if phase == "local_persistence": + return "uncertain", "local_persistence" + if isinstance(exc, IndeterminateSubmitError): + return "uncertain", "indeterminate_submit" + if isinstance(exc, ProviderFailure): + return "failed", exc.kind + if phase == "awaiting_response": + return "uncertain", "indeterminate_submit" + error_class = getattr(exc, "error_class", "") + if error_class: + return "failed", str(error_class) + if phase == "response_received": + return "failed", "empty_or_malformed_success" + if isinstance(exc, (TimeoutError, asyncio.CancelledError, TimeoutOrInterruptedError)): + return "uncertain", "indeterminate_submit" + return "failed", "local_failure" + + @dataclass(frozen=True) class ModelConfig: input_usd_per_million: Decimal @@ -388,13 +422,32 @@ async def connect_credential(self, key: str, *, prior_session="") -> dict: self._refuse_unresolved_credential_change(prior.fingerprint) try: allowed, catalogue_fingerprint = await self._fetch_catalogue(key) - except Exception as exc: - raise CredentialSessionError("xAI rejected the credential") + except AuthError as exc: + raise CredentialConnectError( + "invalid_credential", "xAI rejected the credential.", status=401 + ) from exc + except (TransientTransportOrServerError, TimeoutOrInterruptedError) as exc: + raise CredentialConnectError( + "provider_unavailable", "xAI credential verification is temporarily unavailable.", status=503 + ) from exc + except ProviderFailure as exc: + raise CredentialConnectError( + "provider_rejected", "xAI refused credential verification.", status=502 + ) from exc + except PromptDirectorError as exc: + raise CredentialConnectError( + "provider_response_invalid", "xAI returned an invalid credential verification response.", status=502 + ) from exc with self._lock: prior = self.credential_sessions.resolve(prior_session) if prior.fingerprint: self._refuse_unresolved_credential_change(prior.fingerprint) - resolved = self.credential_sessions.connect(key, prior_session=prior_session) + try: + resolved = self.credential_sessions.connect(key, prior_session=prior_session) + except (OSError, CredentialSessionError) as exc: + raise CredentialConnectError( + "local_persistence", "The verified credential could not be saved locally.", status=500 + ) from exc if prior_session: self._catalogues.pop(prior.fingerprint, None) self._catalogues[resolved.fingerprint] = (catalogue_fingerprint, allowed) @@ -468,30 +521,32 @@ async def generate(self, body: Any, *, credential_session="") -> dict[str, Any]: ) db.commit() - config = self.models[payload["model"]] - prices = TokenPrices( - provider="xai-grok", model=payload["model"], pricing_version=config.pricing_version, - input_usd_per_million=config.input_usd_per_million, - output_usd_per_million=config.output_usd_per_million, - cached_input_usd_per_million=config.cached_input_usd_per_million, - ) - exposure = ( - Decimal(config.input_token_upper_bound) * max( - config.input_usd_per_million, - config.cached_input_usd_per_million or config.input_usd_per_million, - ) - + Decimal(payload["max_output_tokens"]) * config.output_usd_per_million - ) / Decimal(1_000_000) - ledger = TokenLedger(run_id=payload["request_id"], account="xai-grok", ceiling_usd=exposure) - reservation = ledger.reserve( - prices=prices, input_token_upper_bound=config.input_token_upper_bound, - max_output_tokens=payload["max_output_tokens"], - ) + phase = "before_submit" try: + config = self.models[payload["model"]] + prices = TokenPrices( + provider="xai-grok", model=payload["model"], pricing_version=config.pricing_version, + input_usd_per_million=config.input_usd_per_million, + output_usd_per_million=config.output_usd_per_million, + cached_input_usd_per_million=config.cached_input_usd_per_million, + ) + exposure = ( + Decimal(config.input_token_upper_bound) * max( + config.input_usd_per_million, + config.cached_input_usd_per_million or config.input_usd_per_million, + ) + + Decimal(payload["max_output_tokens"]) * config.output_usd_per_million + ) / Decimal(1_000_000) + ledger = TokenLedger(run_id=payload["request_id"], account="xai-grok", ceiling_usd=exposure) + reservation = ledger.reserve( + prices=prices, input_token_upper_bound=config.input_token_upper_bound, + max_output_tokens=payload["max_output_tokens"], + ) request_text = (payload["instructions"] if payload["instructions"].strip() else "Describe these references as one coherent image prompt.") with self._connect() as db: db.execute("UPDATE prompt_requests SET state='submitted',updated_at=? WHERE request_id=?", (time.time(), payload["request_id"])) + phase = "awaiting_response" response = await self.transport( "POST", self.base_url + "/v1/responses", deadline=time.monotonic() + self.deadline_seconds, request_timeout=self.request_timeout, headers={"Authorization": "Bearer " + credential.key}, @@ -510,15 +565,18 @@ async def generate(self, body: Any, *, credential_session="") -> dict[str, Any]: ) if not isinstance(getattr(response, "status", None), int) or not isinstance(getattr(response, "body", None), bytes): raise PromptDirectorError("xAI returned no terminal HTTP response") + phase = "response_received" if not 200 <= response.status < 300: raise failure_from_http(response.status, response.body.decode("utf-8", "replace")) decoded = json.loads(response.body) + if decoded.get("status") in {"queued", "in_progress"}: + raise IndeterminateSubmitError("xAI has not returned a terminal outcome") if decoded.get("status") != "completed": raise PromptDirectorError("xAI response did not complete") content = [item for output in decoded.get("output", []) if output.get("type") == "message" for item in output.get("content", [])] refusal = next((item.get("refusal") for item in content if item.get("type") == "refusal"), None) if refusal: - finish_completion(text="", finish_state="refusal", model=payload["model"], detail=str(refusal)) + raise TextRefusal("xAI refused to generate a prompt") text = "".join(str(item.get("text", "")) for item in content if item.get("type") == "output_text") usage = decoded["usage"] if not isinstance(text, str) or not text.strip(): @@ -535,6 +593,7 @@ async def generate(self, body: Any, *, credential_session="") -> dict[str, Any]: raw_output_tokens = int(usage["output_tokens"]) total_tokens = int(usage.get("total_tokens", input_tokens + raw_output_tokens)) effective_output_tokens = max(raw_output_tokens, max(0, total_tokens - input_tokens)) + phase = "local_persistence" reservation.settle(TokenUsage( input_tokens=input_tokens, output_tokens=effective_output_tokens, cached_input_tokens=int(usage.get("input_tokens_details", {}).get("cached_tokens", 0)), @@ -547,12 +606,9 @@ async def generate(self, body: Any, *, credential_session="") -> dict[str, Any]: db.execute("UPDATE prompt_requests SET state='completed',result=?,updated_at=? WHERE request_id=?", (final_prompt, time.time(), payload["request_id"])) return {"request_id": payload["request_id"], "fingerprint": payload_sha, "state": "completed", "prompt": final_prompt, "final_prompt": final_prompt} except BaseException as exc: - # Failures before the transport boundary are definitely not billed. Once the - # POST begins, ambiguity is retained and the same UUID is never submitted again. + failure_state, failure_category = _submission_failure(exc, phase) with self._connect() as db: - row = db.execute("SELECT state FROM prompt_requests WHERE request_id=?", (payload["request_id"],)).fetchone() - failure_state = "uncertain" if row and row["state"] == "submitted" else "failed" - db.execute("UPDATE prompt_requests SET state=?,error=?,updated_at=? WHERE request_id=?", (failure_state, type(exc).__name__, time.time(), payload["request_id"])) + db.execute("UPDATE prompt_requests SET state=?,error=?,updated_at=? WHERE request_id=?", (failure_state, failure_category, time.time(), payload["request_id"])) raise @@ -641,8 +697,14 @@ async def credential_connect(request): return web.json_response(await backend.connect_credential( body["key"], prior_session=request.headers.get(SESSION_HEADER, "") )) + except CredentialConnectError as exc: + return web.json_response({"error": str(exc), "category": exc.kind}, status=exc.status) + except (UnicodeDecodeError, json.JSONDecodeError, PromptDirectorError): + return web.json_response({"error": "Credential request is invalid.", "category": "invalid_request"}, status=400) + except CredentialSessionError: + return web.json_response({"error": "Credential session is unavailable.", "category": "credential_session"}, status=409) except Exception: - return web.json_response({"error": "Credential verification failed."}, status=401) + return web.json_response({"error": "Credential verification failed.", "category": "local_failure"}, status=500) @routes.post(CREDENTIAL_DISCONNECT_PATH) async def credential_disconnect(request): @@ -657,4 +719,4 @@ async def credential_disconnect(request): return web.json_response({"error": "Credential disconnect failed."}, status=409) -__all__ = ["CREDENTIAL_DISCONNECT_PATH", "CREDENTIAL_PATH", "GET_PATH", "MODELS_PATH", "ModelConfig", "POST_PATH", "PromptDirectorError", "PromptDirectorService", "load_model_configs", "register_routes"] +__all__ = ["CREDENTIAL_DISCONNECT_PATH", "CREDENTIAL_PATH", "CredentialConnectError", "GET_PATH", "MODELS_PATH", "ModelConfig", "POST_PATH", "PromptDirectorError", "PromptDirectorService", "load_model_configs", "register_routes"] diff --git a/_core/resolution_ai_influencer_2k4k/__init__.py b/_core/resolution_ai_influencer_2k4k/__init__.py new file mode 100644 index 0000000..812e4fb --- /dev/null +++ b/_core/resolution_ai_influencer_2k4k/__init__.py @@ -0,0 +1,42 @@ +"""AI-influencer pixel dimensions restricted to the 2K and 4K tiers.""" + +from ..resolution_ai_influencer import ( + AIInfluencerResolutionError, + ASPECT_RATIOS, + calculate_ai_influencer_resolution as _calculate_ai_influencer_resolution, +) + + +RESOLUTION_TIERS = ("2K", "4K") + + +def calculate_ai_influencer_resolution_2k4k(*, aspect_ratio, resolution_tier): + """Return width and height for an approved aspect ratio and 2K/4K tier.""" + if type(resolution_tier) is not str or resolution_tier not in RESOLUTION_TIERS: + raise AIInfluencerResolutionError( + f"resolution_tier must be one of {RESOLUTION_TIERS}" + ) + return _calculate_ai_influencer_resolution( + aspect_ratio=aspect_ratio, + resolution_tier=resolution_tier, + ) + + +def execute_utility_operation(item): + """Execute one compiler-provided 2K/4K resolution payload.""" + if not isinstance(item, dict): + raise AIInfluencerResolutionError("a payload item must be a mapping") + if set(item) != {"aspect_ratio", "resolution_tier"}: + raise AIInfluencerResolutionError( + "a payload item must contain exactly: aspect_ratio, resolution_tier" + ) + return calculate_ai_influencer_resolution_2k4k(**item) + + +__all__ = [ + "AIInfluencerResolutionError", + "ASPECT_RATIOS", + "RESOLUTION_TIERS", + "calculate_ai_influencer_resolution_2k4k", + "execute_utility_operation", +] diff --git a/_core/runtime-assets.json b/_core/runtime-assets.json index 40bbbc5..051723b 100644 --- a/_core/runtime-assets.json +++ b/_core/runtime-assets.json @@ -40,10 +40,50 @@ "sha256": "ec2df62732614e57411cdcf32a23ffdf28910380d03139ee0f4fcbe91eb8c912", "stride": 16, "task": "segm" + }, + "person-seg": { + "allowed_dtypes": [ + "fp32" + ], + "byte_size": 175997641, + "capabilities": [ + "person-seg" + ], + "folder_name": "onnx", + "labels": [ + "person" + ], + "license": "not-declared-in-retained-live-proof", + "logical_id": "rembg/u2net_human_seg.onnx", + "relative_path": "rembg/u2net_human_seg.onnx", + "role": "person-seg", + "sha256": "01eb6a29a5c4d8edb30b56adad9bb3a2a0535338e480724a213e0acfd2d1c73c", + "stride": 1, + "task": "segm" + }, + "skin-parts": { + "allowed_dtypes": [ + "fp32" + ], + "byte_size": 47210581, + "capabilities": [ + "skin-parts" + ], + "folder_name": "onnx", + "labels": [ + "human-parts-20" + ], + "license": "not-declared-in-retained-live-proof", + "logical_id": "human-parts/deeplabv3p-resnet50-human.onnx", + "relative_path": "human-parts/deeplabv3p-resnet50-human.onnx", + "role": "skin-parts", + "sha256": "a6e823a82da10ba24c29adfb544130684568c46bfac865e215bbace3b4035a71", + "stride": 1, + "task": "segm" } }, "proof": { - "sha256": "e9cd32a5c2b214f1570a3f9860144bbc0fec72d4a53734555752a8b4a3a34f91" + "sha256": "917902e05bb4e0758e6ba9b264075f89edbc0b2b39580fb2cb55d6e9c76945f8" }, "schema": "matrix-lab.comfyui-runtime-assets/v1" } diff --git a/_core/runtime_bootstrap.py b/_core/runtime_bootstrap.py index 4696c26..d69ab1f 100644 --- a/_core/runtime_bootstrap.py +++ b/_core/runtime_bootstrap.py @@ -7,7 +7,7 @@ import threading -_EXPECTED_REGISTRY_SHA256 = 'ba2964519d538b65fafbbe2fbad5a9e7d16940334f49fcc517ddab041c59a5c4' +_EXPECTED_REGISTRY_SHA256 = 'ab1955cfe5e8d05b832a4f63cae121d9afd6f74d3a9048b0107cf634659cada8' _HAS_EYE = False _HAS_DETAIL = False _HAS_SPECTRAL = True diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..64883aa --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,26 @@ +# Compatibility + +## Current status + +Version 0.2.0 is a development candidate. Offline checks cover package registration, schemas, category layout, deterministic builds, runtime registry integrity, and frontend syntax. Those checks do not establish complete live acceptance for Classic and Nodes 2.0 renderers, every interaction, every GPU/model combination, or production use. + +| Area | Current boundary | +| --- | --- | +| Python | Package metadata requires Python 3.10 or newer. | +| ComfyUI frontend | Shared presentation targets Classic and Nodes 2.0; current exact-build full interaction acceptance remains pending. Native widgets remain the serialized execution state. | +| Torch/CUDA | Uses the host ComfyUI stack. No universal hardware, Torch, or CUDA matrix is claimed. | +| Resolution nodes | Model-independent integer geometry; output dimensions do not certify that a model can generate or process that size. | +| Image processing | Photo Finisher is local Torch processing. Easy Crop and Image Batch Loader operate on static uploaded images. | +| Skin Mask | Needs separately acquired, licensed, and configured segmentation assets plus compatible runtimes. Installation does not complete this setup. An all-parts-off run can return an empty mask without inference. | +| Eye Mask | Needs the registered eye detector and, when enabled, SAM refinement assets/runtime. Model weights are not bundled; installation does not complete this setup. | +| Sampling/detail | Requires compatible ComfyUI MODEL, NOISE, CONDITIONING, LATENT, VAE, SAMPLER, or UPSCALE_MODEL inputs as documented per node. Sampler availability follows the host ComfyUI contract. | +| Prompt Director | Ordinary graph execution is local. Explicit Generate Prompt requires a supported xAI credential/model and may be paid. | +| Platforms | No broad Windows, Linux, macOS, cloud, or portable-build support claim is made until each environment is accepted with the exact release bytes. | + +## External assets + +The distribution contains logical names and hashes for required runtime assets but no model weights. A matching filename alone is insufficient. Missing, unregistered, or hash-mismatched assets must fail rather than silently selecting another model. Follow [detector and segmentation setup](detector-setup.md) for exact paths, sources, hashes, runtime requirements, and the license evidence currently available for each asset. + +## Workflow compatibility + +Stable class IDs help existing workflows locate retained nodes; they do not guarantee that older widget or output positions remain compatible. Read [migration.md](migration.md) for the finishing and resolution changes. Keep a rollback copy and validate duplicate workflows before production use. diff --git a/docs/detector-setup.md b/docs/detector-setup.md new file mode 100644 index 0000000..a1a41d9 --- /dev/null +++ b/docs/detector-setup.md @@ -0,0 +1,48 @@ +# Detector and segmentation setup + +MATRIX LAB NODES does not bundle, download, or install model weights or optional detector runtimes. Package import can succeed while `MATRIX_SkinMask` and `MATRIX_EyeMask` remain unavailable. Acquire each asset from its cited source only after reviewing its license and suitability for your use, then verify the exact SHA-256 before execution. + +Paths below are relative to the active ComfyUI root. Do not rename a different model to match an expected filename; the runtime checks both logical identity and content hash. + +## Required assets + +| Used by | ComfyUI-relative path | Expected SHA-256 | Source | License evidence and limit | +| --- | --- | --- | --- | --- | +| Eye Mask detection | `models/ultralytics/bbox/Eyeful_v2-Individual.pt` | `278fee230b1be01cfb8c47c3f9ad7118c7aa33149a2249b80cea4da236361fbe` | [Pinned Hugging Face file](https://huggingface.co/Bryan32/Adetailer/blob/701874bdc5ebc0db00543eb867dd0e778db93d1c/Eyeful_v2-Individual.pt) | A license for this weight has not been established from the retained source evidence. Review before use or redistribution. | +| Eye Mask SAM refinement | `models/sams/sam_vit_b_01ec64.pth` | `ec2df62732614e57411cdcf32a23ffdf28910380d03139ee0f4fcbe91eb8c912` | [Official model file](https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth) | The [Segment Anything repository](https://github.com/facebookresearch/segment-anything) publishes Apache-2.0 terms. Confirm those terms cover your intended use of the weight. | +| Skin Mask person gate | `models/onnx/rembg/u2net_human_seg.onnx` | `01eb6a29a5c4d8edb30b56adad9bb3a2a0535338e480724a213e0acfd2d1c73c` | [Pinned Hugging Face file](https://huggingface.co/jellybox/u2net-human-seg/resolve/736b768145e597134968bde9ace5bf8fd19ffa8c/u2net_human_seg.onnx) | The host and [upstream U-2-Net repository](https://github.com/xuebinqin/U-2-Net) identify Apache-2.0 terms; the conversion lineage has not been fully established here. | +| Skin Mask human parts | `models/onnx/human-parts/deeplabv3p-resnet50-human.onnx` | `a6e823a82da10ba24c29adfb544130684568c46bfac865e215bbace3b4035a71` | [Hugging Face file](https://huggingface.co/Metal3d/deeplabv3p-resnet50-human/blob/main/deeplabv3p-resnet50-human.onnx) | The host model card uses CC0 but questions the underlying model license. Treat the underlying license as unresolved. | + +SAM is needed only when Eye Mask's `sam_refine` is enabled. The person-segmentation weight is needed when Skin Mask's `person_gate` is enabled. Skin-part inference still needs the human-parts weight whenever any body-part switch is enabled. + +## Verify the files + +From the active ComfyUI root, calculate SHA-256 without opening or modifying the weights. + +PowerShell: + +```powershell +Get-FileHash -Algorithm SHA256 -LiteralPath .\models\ultralytics\bbox\Eyeful_v2-Individual.pt +Get-FileHash -Algorithm SHA256 -LiteralPath .\models\sams\sam_vit_b_01ec64.pth +Get-FileHash -Algorithm SHA256 -LiteralPath .\models\onnx\rembg\u2net_human_seg.onnx +Get-FileHash -Algorithm SHA256 -LiteralPath .\models\onnx\human-parts\deeplabv3p-resnet50-human.onnx +``` + +Linux or macOS shell: + +```bash +sha256sum models/ultralytics/bbox/Eyeful_v2-Individual.pt +sha256sum models/sams/sam_vit_b_01ec64.pth +sha256sum models/onnx/rembg/u2net_human_seg.onnx +sha256sum models/onnx/human-parts/deeplabv3p-resnet50-human.onnx +``` + +Every result must match the table exactly. Stop on a missing or mismatched file. Do not bypass the registry, substitute a similarly named model, or edit `_core/runtime-assets.json`. + +## Optional runtime packages + +The detector paths may need `onnxruntime`, `ultralytics`, and `segment-anything` in the same Python environment that launches ComfyUI. Development validation observed versions 1.29.0, 8.4.142, and 1.0 respectively; these observations are not minimum supported versions or installation pins. Inspect the environment and package requirements before making changes, especially where Torch/CUDA could be affected. + +Ultralytics publishes AGPL-3.0 and Enterprise licensing options in its [official licensing guidance](https://github.com/ultralytics/ultralytics/blob/main/docs/en/help/contributing.md). Which terms apply to this proprietary pack and a particular deployment has not been established by this release documentation. Review the current upstream terms before installation or distribution. + +After the exact assets and compatible runtimes are present, restart ComfyUI and test each mask node with non-sensitive local media. A successful import or hash check alone does not prove inference compatibility, output quality, or license suitability. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..4c2b186 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,36 @@ +# Migration to the unified package + +Version 0.2.0 replaces two older packs with one package. Back up workflows and the existing installations before switching. The older `MATRIXLAB-Nodes` and `MATRIXLAB-UI-Nodes` packs must remain disabled while this package is enabled because retained class IDs otherwise register twice. + +## Finishing-node replacement + +`MATRIX_CameraLook` and `MATRIX_Renoise` are retired. Replace their chain with `MATRIX_PhotoFinisher`, reconnect the IMAGE path, and tune the result from the new defaults: + +- profile: `Everyday Capture` +- mix: `1` +- texture: `1` +- detail: `1` +- contrast: `0` +- warmth: `0` +- saturation: `1` +- seed: `42` + +There is no exact mapping for the old ISO, JPEG, or motion controls. Photo Finisher applies creative, display-referred tone, color, detail, and texture; it does not reproduce camera sensor, RAW, ISO, demosaic, motion-blur, chromatic-fringe, or JPEG behavior. Existing workflows that reference either retired class load it as missing until it is replaced manually. + +## Resolution changes + +`MATRIXLAB_Resolution` now returns only `width` and `height`. Its canonical controls are `aspect_ratio`, `resolution_tier`, `custom_width`, and `custom_height`. A former `max_side` or `divisible_by` value cannot be copied by widget position. Recreate the node and reconnect outputs by name and meaning. + +`MATRIXLAB_AIInfluencerResolution` also returns only `width` and `height`. Older saved versions exposed six render, delivery, sampler-scale, and plan outputs. Replace those nodes manually and review every downstream link because output indices no longer share the same meaning. The current `1K` choice means an exact 1024-pixel longer edge. + +`MATRIX_ResolutionPlan`, `MATRIXLAB_Resolution2K`, and `MATRIXLAB_Resolution4KProgressive` are not included. Workflows using them require deliberate replacement. `MATRIXLAB_AIInfluencerResolution2K4K` is a two-output geometry selector and is not an ABI-compatible substitute for the older connected model/sampler nodes. + +Current Krea 2 graphs that still contain `MATRIX_CameraLook` or `MATRIX_Renoise` are not drop-in compatible with this package. Migrate those graphs manually before treating the unified pack as their replacement. + +## Retained class IDs + +Other base classes retain their class IDs, but widget schemas and runtime assets must still be checked against [NODES.md](../NODES.md) and the installed manifest. Open a copy of each important workflow, inspect missing nodes and shifted widgets, save, reload, and verify links before running model-dependent paths. + +## Rollback reference + +The legacy predecessor revision is `eaa50a674005b4e7bf7be13f0c289faeb37bfcef`. Keep the old installation disabled and intact until migrated workflows pass. Restore that exact revision as a separate rollback operation; do not copy shared `_core`, `nodes`, or `web` folders between versions. diff --git a/docs/nodes/ai-influencer-resolution-2k4k.md b/docs/nodes/ai-influencer-resolution-2k4k.md new file mode 100644 index 0000000..73029cc --- /dev/null +++ b/docs/nodes/ai-influencer-resolution-2k4k.md @@ -0,0 +1,12 @@ +# MATRIX AI INFLUENCER RESOLUTION 2K/4K + +Class ID: `MATRIXLAB_AIInfluencerResolution2K4K` + +Category: `MATRIX LAB/Resolution & Layout` + +This class uses the same pixel calculation as MATRIX AI INFLUENCER RESOLUTION while exposing only `2K` and `4K`. + +- Required: `aspect_ratio` (`1:1`, `9:16`, or `3:4`), `resolution_tier` (`2K` or `4K`) +- Outputs: `width` (`INT`), `height` (`INT`) + +It rejects `1K` and returns geometry only. It does not connect or validate a model or sampler and is not a replacement for the retired `MATRIXLAB_Resolution2K` or `MATRIXLAB_Resolution4KProgressive` ABI. diff --git a/docs/nodes/ai-influencer-resolution.md b/docs/nodes/ai-influencer-resolution.md new file mode 100644 index 0000000..d766094 --- /dev/null +++ b/docs/nodes/ai-influencer-resolution.md @@ -0,0 +1,12 @@ +# MATRIX AI INFLUENCER RESOLUTION + +Class ID: `MATRIXLAB_AIInfluencerResolution` + +Category: `MATRIX LAB/Resolution & Layout` + +A focused, model-independent pixel selector for portrait-oriented workflows. + +- Required: `aspect_ratio` (`1:1`, `9:16`, or `3:4`; default `3:4`), `resolution_tier` (`1K`, `2K`, or `4K`; default `2K`) +- Outputs: `width` (`INT`), `height` (`INT`) + +The selected tier is an exact longer edge of 1024, 2048, or 4096 pixels. For example, `3:4` at `2K` returns 1536 by 2048. The node returns geometry only: it has no sampler plan, delivery-size outputs, model checks, or claim that a connected model supports the result. Older six-output workflows require manual migration. diff --git a/docs/nodes/auto-prompter.md b/docs/nodes/auto-prompter.md new file mode 100644 index 0000000..e4218e9 --- /dev/null +++ b/docs/nodes/auto-prompter.md @@ -0,0 +1,11 @@ +# MATRIX AUTO PROMPTER + +Class ID: `MATRIXLAB_PromptDirector` + +Category: `MATRIX LAB/Prompting` + +Returns the editable saved `final_prompt` as `prompt` (`STRING`). Ordinary graph execution performs no I/O and makes no provider request. + +The optional `images` input accepts saved local references from MATRIX IMAGE BATCH LOADER or a compatible native Load Image path. The node stores instructions, system prompt, selected model, final prompt, generation state, and character trigger as its visible execution state. + +The explicit **Generate Prompt** action can send one to ten ordered local images and the prompt fields to xAI. It requires a valid credential, supported model, and literal paid-use authorization. The masked credential stays outside workflow serialization. Failed, uncertain, or repeated requests fail closed rather than silently resubmitting. Review images and text before using the action; provider charges and terms may apply. diff --git a/docs/nodes/crop-tail-paste.md b/docs/nodes/crop-tail-paste.md new file mode 100644 index 0000000..9b95ebe --- /dev/null +++ b/docs/nodes/crop-tail-paste.md @@ -0,0 +1,13 @@ +# MATRIX CROP TAIL PASTE + +Class ID: `MATRIX_CropTailPaste` + +Category: `MATRIX LAB/Sampling & Detail` + +Runs a masked latent tail at crop scale and pastes the processed region back into the source image. + +- Required sockets: `image`, `mask`, `model`, `noise`, `positive`, `vae` +- Main options: `guide_size` (default `1024`), `padding_px` (`64`), `start_sigma` (`0.22`), `steps` (`3`), `sampler_name` (`euler_ancestral`), `scheduler` (`beta57`), `feather_px` (`12`), `color_match` (`false`), `mask_mode` (`legacy_grow_feather`) +- Output: `image` (`IMAGE`) + +It is suited to small regions such as eyes. A disconnected multi-person mask may form one bounding crop per frame. Validate mask coverage and the selected model/VAE path before relying on the result. diff --git a/docs/nodes/easy-crop.md b/docs/nodes/easy-crop.md new file mode 100644 index 0000000..35b61bc --- /dev/null +++ b/docs/nodes/easy-crop.md @@ -0,0 +1,12 @@ +# MATRIX EASY CROP + +Class ID: `MATRIXLAB_EasyCrop` + +Category: `MATRIX LAB/Image Processing` + +Loads one static ComfyUI input image and returns the visible crop plus its alpha-derived mask. + +- Required controls: `image`, `aspect_ratio` (`Free`, `1:1`, `16:9`, `9:16`, `Custom`), `custom_ratio_width`, `custom_ratio_height`, and normalized `crop_x`, `crop_y`, `crop_width`, `crop_height` +- Outputs: `image` (`IMAGE`), `mask` (`MASK`) + +The backend applies EXIF orientation before crop geometry. RGB, grayscale, and RGBA images are accepted; animated images, malformed paths, invalid rectangles, and images above the declared size limit are rejected. Sources without alpha return a zero mask. The node does not modify the source file. diff --git a/docs/nodes/eye-mask.md b/docs/nodes/eye-mask.md new file mode 100644 index 0000000..171bc17 --- /dev/null +++ b/docs/nodes/eye-mask.md @@ -0,0 +1,13 @@ +# MATRIX EYE MASK + +Class ID: `MATRIX_EyeMask` + +Category: `MATRIX LAB/Masks & Detection` + +Detects eye boxes, retains the highest-confidence candidates, optionally refines each with SAM, and returns union and per-eye masks. + +- Required: `image` (`IMAGE`) +- Options: `detector` (registered `bbox/Eyeful_v2-Individual.pt`), `resolution` (`1280`), `threshold` (`0.5`), `min_size_px` (`24`), `max_eyes` (`2`), `sam_refine` (`true`), `feather_px` (`6`), `sam_erosion_px` (`10`) +- Outputs: `mask`, `masks`, `bboxes`, `preview`; all follow the pack's list-output contract + +Selection is global per frame and ordered left-to-right after confidence filtering; it does not assign eyes to people. No detections return valid empty outputs. Missing or invalid detector/SAM assets and failed refinement raise an error rather than returning a misleading rectangle. Complete [detector and segmentation setup](../detector-setup.md) before execution. diff --git a/docs/nodes/image-batch-loader.md b/docs/nodes/image-batch-loader.md new file mode 100644 index 0000000..621dd3c --- /dev/null +++ b/docs/nodes/image-batch-loader.md @@ -0,0 +1,12 @@ +# MATRIX IMAGE BATCH LOADER + +Class ID: `MATRIXLAB_ImageBatchLoader` + +Category: `MATRIX LAB/Input & Output` + +Loads an ordered collection of one to ten static JPEG or PNG files from ComfyUI input storage. Dragging, removal, and reorder operations update one canonical `collection` value. The selected tile controls preview only; execution processes every item in order. + +- Required: `collection` (`STRING`, managed by the gallery) +- Outputs: `images` (`IMAGE` list), `masks` (`MASK` list) + +Each image retains its own dimensions. The node does not stack, pad, crop, or resize mixed sizes. EXIF orientation is applied; alpha becomes the matching ComfyUI mask. Empty collections, duplicates, traversal paths, animated images, malformed state, or missing files fail closed. diff --git a/docs/nodes/latent-tail.md b/docs/nodes/latent-tail.md new file mode 100644 index 0000000..b30abf8 --- /dev/null +++ b/docs/nodes/latent-tail.md @@ -0,0 +1,13 @@ +# MATRIX LATENT TAIL + +Class ID: `MATRIX_LatentTail` + +Category: `MATRIX LAB/Sampling & Detail` + +Runs a short tail sampling pass on a latent, optionally limited by a mask. + +- Required sockets: `model` (`MODEL`), `noise` (`NOISE`), `positive` (`CONDITIONING`), `latent` (`LATENT`) +- Options: `start_sigma` (default `0.26`), `steps` (`3`), `sampler_name` (`euler_ancestral`), `scheduler` (`beta57`), optional `mask` +- Output: `latent` (`LATENT`) + +The node is a compact replacement for wiring the equivalent scheduler, noise-mask, guider, and advanced sampler tail manually. Its result still depends on compatible model, conditioning, noise, sampler, scheduler, and latent inputs. diff --git a/docs/nodes/matrix-save-clean.md b/docs/nodes/matrix-save-clean.md new file mode 100644 index 0000000..84c3cb2 --- /dev/null +++ b/docs/nodes/matrix-save-clean.md @@ -0,0 +1,12 @@ +# MATRIX METADATA KILLER + +Class ID: `MATRIX_SaveClean` + +Category: `MATRIX LAB/Input & Output` + +Saves every input image as JPEG or PNG without accepting prompt or workflow metadata. It is an output node and has no output sockets. + +- Required: `images` (`IMAGE`) +- Options: `filename_prefix` (default `MATRIX`), `format` (`JPEG` or `PNG`, default `JPEG`), `quality` (1–100, default `100`) + +JPEG uses fixed 4:4:4 chroma sampling. PNG is lossless. Container output is validated before atomic publication. This node does not remove information already burned into visible pixels. diff --git a/docs/nodes/output-stage.md b/docs/nodes/output-stage.md new file mode 100644 index 0000000..786e7df --- /dev/null +++ b/docs/nodes/output-stage.md @@ -0,0 +1,13 @@ +# MATRIX OUTPUT STAGE + +Class ID: `MATRIX_OutputStage` + +Category: `MATRIX LAB/Image Processing` + +Resolves an image toward explicit target dimensions through the connected model, noise, conditioning, and VAE path, with optional model upscaling. + +- Required: `image`, `model`, `noise`, `positive`, `vae`, `target_width`, `target_height` +- Options: `upscale_model`, `sigma_base` (default `0.15`), `sigma_per_octave` (`0.1`), `steps` (`3`), `sampler_name` (`res_multistep`), `scheduler` (`beta57`), `resize_method` (`lanczos`), `decode_tile` (`512`), `decode_overlap` (`64`) +- Outputs: `image` (`IMAGE`), `info` (`STRING`) + +The accepted behavior includes shrink-or-pass use. Quality, memory use, and supported dimensions depend on the connected model, VAE, optional upscale model, and host hardware; this node does not certify them. diff --git a/docs/nodes/photo-finisher.md b/docs/nodes/photo-finisher.md new file mode 100644 index 0000000..b8e894c --- /dev/null +++ b/docs/nodes/photo-finisher.md @@ -0,0 +1,44 @@ +# MATRIX PHOTO FINISHER + +Class ID: `MATRIX_PhotoFinisher` + +Category: `MATRIX LAB/Image Processing` + +Applies deterministic, display-referred photographic finishing while preserving image shape, batch order, device, dtype, and alpha. It runs locally with Torch and makes no network request. + +## Profiles + +Profiles set the starting balance; the controls below trim that profile. + +| Profile | Character | +| --- | --- | +| `Clean Digital` | The lightest texture and neutral color, with a modest crisp detail base. | +| `Everyday Capture` | A balanced default with mild contrast and warmth, restrained saturation, and moderate texture. | +| `Low Light` | Softer detail and contrast with the warmest, strongest, broadest texture of the three profiles. | + +These are creative calibrations for display-referred images. They are not measured camera, sensor, RAW, or ISO simulations. + +## Controls + +| Input | Range and default | Meaning | +| --- | --- | --- | +| `image` | Required `IMAGE` | Finite RGB or RGBA BHWC tensor in `[0,1]`, using fp16, fp32, or bf16. | +| `mask` | Optional `MASK` | Application weight: `0` preserves source pixels and `1` applies the selected `mix`. Soft values blend proportionally. | +| `profile` | Three choices; `Everyday Capture` | Selects the base tone, color, detail, and texture calibration. | +| `mix` | `0..1`; `1` | Blends the complete finished result with the source. Zero returns the original image unchanged. | +| `texture` | `0..2`; `1` | Scales the profile's deterministic luminance and chroma texture. Zero disables texture. | +| `detail` | `-1..1`; `1` | Trims profile detail. Negative values reduce detail and can soften; positive values sharpen. | +| `contrast` | `-1..1`; `0` | Adds or reduces contrast around the profile's base contrast. | +| `warmth` | `-1..1`; `0` | Shifts midtones warmer or cooler around the profile's base warmth. | +| `saturation` | `0..2`; `1` | Multiplies the profile's chroma. Zero produces grayscale. | +| `seed` | `0..4294967295`; `42` | Fixes coordinate-based texture. It does not change after generation automatically. | + +The output is one `IMAGE`. New nodes start with `Everyday Capture`, mix `1`, texture `1`, detail `1`, contrast `0`, warmth `0`, saturation `1`, and seed `42`. + +## Mask and image rules + +The mask must be floating-point BHW data in `[0,1]` with exactly the same height and width as the image. Its batch must be one, which broadcasts across the image batch, or equal the image batch for framewise masks. The node never resizes or guesses a mask. An all-zero mask returns the original image unchanged; exact-zero pixels remain exact source pixels. + +RGB and RGBA inputs retain their exact shape. Alpha is copied unchanged. The operation does not resize, crop, compress, edit metadata, mutate inputs, or use global random state. Invalid ranks, channels, dimensions, batches, dtypes, nonfinite values, or out-of-range values fail before a result is returned. + +Try the local, provider-free [Photo Finisher example](../../examples/photo-finisher.json) with your own PNG or JPEG input. diff --git a/docs/nodes/resolution.md b/docs/nodes/resolution.md new file mode 100644 index 0000000..15a7700 --- /dev/null +++ b/docs/nodes/resolution.md @@ -0,0 +1,14 @@ +# MATRIX RESOLUTION + +Class ID: `MATRIXLAB_Resolution` + +Category: `MATRIX LAB/Resolution & Layout` + +Calculates integer pixel dimensions without loading a model or allocating an image. + +- Required controls: `aspect_ratio`, `resolution_tier`, `custom_width`, `custom_height` +- Ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3`, `4:5`, `Custom` +- Tiers: `1K`, `2K`, `4K` +- Outputs: `width` (`INT`), `height` (`INT`) + +The tier sets the longer edge exactly to 1024, 2048, or 4096 pixels and rounds the shorter edge to the nearest integer. `Custom` returns each supplied whole-pixel side exactly within 1–16384. No hidden alignment is applied. These dimensions do not certify model compatibility. diff --git a/docs/nodes/skin-mask.md b/docs/nodes/skin-mask.md new file mode 100644 index 0000000..190ecf0 --- /dev/null +++ b/docs/nodes/skin-mask.md @@ -0,0 +1,13 @@ +# MATRIX SKIN MASK + +Class ID: `MATRIX_SkinMask` + +Category: `MATRIX LAB/Masks & Detection` + +Builds a skin-region mask from selected human-part classes, with an optional person-silhouette gate. + +- Required: `image` (`IMAGE`) +- Options: `face`, `torso`, `arms`, `legs` (all default `true`), `feather_px` (`16`), `edge_radius_px` (`8`), `expand_px` (`4`), `person_gate` (`true`) +- Outputs: `mask` (`MASK`), `preview` (`IMAGE`) + +Selected regions cover every represented person; this is still-image batch processing without identity tracking. Turning all four part switches off returns an empty mask without inference. Other runs require separately configured segmentation assets and runtimes. Missing configuration fails instead of downloading or guessing a model. Complete [detector and segmentation setup](../detector-setup.md) before testing a nonempty selection. diff --git a/docs/nodes/spectral-sampler.md b/docs/nodes/spectral-sampler.md new file mode 100644 index 0000000..47a9b87 --- /dev/null +++ b/docs/nodes/spectral-sampler.md @@ -0,0 +1,11 @@ +# MATRIX SPECTRAL SAMPLER + +Class ID: `MATRIXSpectralSampler` + +Category: `MATRIX LAB/Sampling & Detail` + +Provides a ComfyUI `SAMPLER`. `base_sampler` defaults to `euler`; its choices follow the sampler names available in the supported host ComfyUI contract. Native passthrough is obtained with a single scale of `1.0`. + +Spectral controls include `transform` (`dwt` by default), `mode` (`delta_optimal`), `model_preset` (`custom`), `scales` (`0.5,1.0`), `delta` (`0.01`), `manual_sigmas` (`0.85`), spectrum parameters, and `spectral_seed` (`1088164640`). + +The output is `sampler` (`SAMPLER`). Progressive behavior depends on the chosen solver and workflow; the node does not establish model training or native 4K support. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b9c92ce --- /dev/null +++ b/examples/README.md @@ -0,0 +1,8 @@ +# Photo Finisher example + +Open [photo-finisher.json](photo-finisher.json) in ComfyUI after installing the pack. +Choose your own PNG or JPEG with **Load Image**, then press **Run**. The graph applies +the approved Photo Finisher defaults and displays the result in Preview Image. +The input is intentionally unselected; no photograph or model weights are bundled. +No provider call or model generation is used. Preview Image displays the result; +add a save node if you want to retain it. diff --git a/examples/photo-finisher.json b/examples/photo-finisher.json new file mode 100644 index 0000000..544c22d --- /dev/null +++ b/examples/photo-finisher.json @@ -0,0 +1,150 @@ +{ + "revision": 0, + "last_node_id": 3, + "last_link_id": 2, + "nodes": [ + { + "id": 1, + "type": "MATRIX_PhotoFinisher", + "pos": [ + 430, + 140 + ], + "size": [ + 396.3501953125, + 507 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 1 + }, + { + "name": "mask", + "shape": 7, + "type": "MASK", + "link": null + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "MATRIX_PhotoFinisher" + }, + "widgets_values": [ + "Everyday Capture", + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 42 + ] + }, + { + "id": 2, + "type": "LoadImage", + "pos": [ + 40, + 140 + ], + "size": [ + 282.798828125, + 314 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "", + "image" + ] + }, + { + "id": 3, + "type": "PreviewImage", + "pos": [ + 960, + 140 + ], + "size": [ + 270, + 270 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": null + } + ], + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [], + "title": "Finished image" + } + ], + "links": [ + [ + 1, + 2, + 0, + 1, + 0, + "IMAGE" + ], + [ + 2, + 1, + 0, + 3, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": {}, + "version": 0.4 +} diff --git a/nodes/image_processing/matrix_cameralook.py b/nodes/image_processing/matrix_photofinisher.py similarity index 74% rename from nodes/image_processing/matrix_cameralook.py rename to nodes/image_processing/matrix_photofinisher.py index e47a7d5..b991616 100644 --- a/nodes/image_processing/matrix_cameralook.py +++ b/nodes/image_processing/matrix_photofinisher.py @@ -1,22 +1,24 @@ -"""Compiled declaration for MATRIX_CameraLook; regenerate instead of hand-editing.""" +"""Compiled declaration for MATRIX_PhotoFinisher; regenerate instead of hand-editing.""" from __future__ import annotations from ..._core.flow_utility import execute_compiled_node -NODE_ID = 'MATRIX_CameraLook' -OPERATION_BLOCK = 'color.camera-look' +NODE_ID = 'MATRIX_PhotoFinisher' +OPERATION_BLOCK = 'image.photo-finisher' SCHEMA_WIDGETS = { - 'image': ('IMAGE', {'forceInput': True}), - 'enabled': ('BOOLEAN', {'default': True}), - 'chromatic_aberration': ('FLOAT', {'default': 0.35, 'min': 0.0, 'max': 10.0}), - 'demosaic_pixel_blur': ('BOOLEAN', {'default': True}), - 'noise_strength': ('FLOAT', {'default': 1.9, 'min': 0.0, 'max': 5.0}), - 'kernel_motion_blur': ('INT', {'default': 1, 'min': 1, 'max': 51}), - 'jpeg_compression': ('INT', {'default': 98, 'min': 85, 'max': 100}), - 'look_seed': ('INT', {'default': 0, 'min': 0, 'max': 18446744073709551615}), + 'image': ('IMAGE', {'tooltip': 'BHWC RGB or RGBA image batch.', 'forceInput': True}), + 'mask': ('MASK', {'tooltip': 'Optional BHW application mask; 0 preserves and 1 applies.', 'forceInput': True}), + 'profile': (['Clean Digital', 'Everyday Capture', 'Low Light'], {'default': 'Everyday Capture'}), + 'mix': ('FLOAT', {'default': 1.0, 'min': 0.0, 'max': 1.0, 'step': 0.01}), + 'texture': ('FLOAT', {'default': 1.0, 'min': 0.0, 'max': 2.0, 'step': 0.05}), + 'detail': ('FLOAT', {'default': 1.0, 'tooltip': 'Trim profile detail: negative reduces it and can soften; positive sharpens.', 'min': -1.0, 'max': 1.0, 'step': 0.05}), + 'contrast': ('FLOAT', {'default': 0.0, 'min': -1.0, 'max': 1.0, 'step': 0.05}), + 'warmth': ('FLOAT', {'default': 0.0, 'min': -1.0, 'max': 1.0, 'step': 0.05}), + 'saturation': ('FLOAT', {'default': 1.0, 'min': 0.0, 'max': 2.0, 'step': 0.05}), + 'seed': ('INT', {'default': 42, 'control_after_generate': False, 'min': 0, 'max': 4294967295}), } IMAGE_UPLOAD_FIELDS = () -INPUT_SOCKET_TYPES = {'image': 'IMAGE', 'enabled': 'BOOLEAN', 'chromatic_aberration': 'FLOAT', 'demosaic_pixel_blur': 'BOOLEAN', 'noise_strength': 'FLOAT', 'kernel_motion_blur': 'INT', 'jpeg_compression': 'INT', 'look_seed': 'INT'} +INPUT_SOCKET_TYPES = {'image': 'IMAGE', 'mask': 'MASK', 'profile': 'STRING', 'mix': 'FLOAT', 'texture': 'FLOAT', 'detail': 'FLOAT', 'contrast': 'FLOAT', 'warmth': 'FLOAT', 'saturation': 'FLOAT', 'seed': 'INT'} REQUIRED_INPUT_NAMES = ('image',) OUTPUT_SOCKET_TYPES = ('IMAGE',) BATCH_POLICY = 'exactly-one' @@ -144,7 +146,7 @@ def _payload_items(inputs): for index in range(count) ) -class MATRIXCameraLook: +class MATRIXPhotoFinisher: @classmethod def INPUT_TYPES(cls): return { @@ -152,13 +154,15 @@ def INPUT_TYPES(cls): 'image': _input_widget('image'), }, "optional": { - 'enabled': _input_widget('enabled'), - 'chromatic_aberration': _input_widget('chromatic_aberration'), - 'demosaic_pixel_blur': _input_widget('demosaic_pixel_blur'), - 'noise_strength': _input_widget('noise_strength'), - 'kernel_motion_blur': _input_widget('kernel_motion_blur'), - 'jpeg_compression': _input_widget('jpeg_compression'), - 'look_seed': _input_widget('look_seed'), + 'mask': _input_widget('mask'), + 'profile': _input_widget('profile'), + 'mix': _input_widget('mix'), + 'texture': _input_widget('texture'), + 'detail': _input_widget('detail'), + 'contrast': _input_widget('contrast'), + 'warmth': _input_widget('warmth'), + 'saturation': _input_widget('saturation'), + 'seed': _input_widget('seed'), }, } @@ -166,13 +170,13 @@ def INPUT_TYPES(cls): RETURN_NAMES = ('image',) FUNCTION = "execute" CATEGORY = 'MATRIX LAB/Image Processing' - DESCRIPTION = 'Generated from operation block color.camera-look.' + DESCRIPTION = 'Applies a deterministic photographic finish with profile, texture, detail, tone, color, and optional mask controls.' INPUT_IS_LIST = INPUT_IS_LIST OUTPUT_IS_LIST = OUTPUT_IS_LIST async def execute(self, **inputs): inputs = _adapt_image_inputs(_fill_widget_defaults(inputs)) - from ..._core import color_camera_look as _operation_block + from ..._core import image_photo_finisher as _operation_block inputs['__flow_runtime__'] = { 'resolved_blocks': {OPERATION_BLOCK: _operation_block}, 'input_socket_types': INPUT_SOCKET_TYPES, @@ -185,5 +189,5 @@ async def execute(self, **inputs): } return await execute_compiled_node(NODE_ID, '', '', inputs) -NODE_CLASS_MAPPINGS = {NODE_ID: MATRIXCameraLook} -NODE_DISPLAY_NAME_MAPPINGS = {NODE_ID: 'MATRIX CAMERA LOOK'} +NODE_CLASS_MAPPINGS = {NODE_ID: MATRIXPhotoFinisher} +NODE_DISPLAY_NAME_MAPPINGS = {NODE_ID: 'MATRIX PHOTO FINISHER'} diff --git a/nodes/image_processing/matrix_renoise.py b/nodes/resolution_layout/matrixlab_aiinfluencerresolution2k4k.py similarity index 82% rename from nodes/image_processing/matrix_renoise.py rename to nodes/resolution_layout/matrixlab_aiinfluencerresolution2k4k.py index b2d6998..70cbd99 100644 --- a/nodes/image_processing/matrix_renoise.py +++ b/nodes/resolution_layout/matrixlab_aiinfluencerresolution2k4k.py @@ -1,23 +1,22 @@ -"""Compiled declaration for MATRIX_Renoise; regenerate instead of hand-editing.""" +"""Compiled declaration for MATRIXLAB_AIInfluencerResolution2K4K; regenerate instead of hand-editing.""" from __future__ import annotations +from ..._core import resolution_ai_influencer_2k4k as _operation_block from ..._core.flow_utility import execute_compiled_node -NODE_ID = 'MATRIX_Renoise' -OPERATION_BLOCK = 'image.renoise' +NODE_ID = 'MATRIXLAB_AIInfluencerResolution2K4K' +OPERATION_BLOCK = 'resolution.ai-influencer-2k4k' SCHEMA_WIDGETS = { - 'image': ('IMAGE', {'tooltip': 'BHWC image batch.', 'forceInput': True}), - 'grain_seed': ('INT', {'default': 0, 'min': 0, 'max': 18446744073709551615}), - 'iso_preset': (['ISO 0 (Off)', 'ISO 50', 'ISO 100 (Clean)', 'ISO 200', 'ISO 400', 'ISO 800', 'ISO 1600', 'Night Mode'], {'default': 'ISO 400'}), - 'strength': ('FLOAT', {'default': 1.0, 'min': 0.0, 'max': 2.0, 'step': 0.05}), + 'aspect_ratio': (['1:1', '9:16', '3:4'], {'default': '3:4', 'tooltip': 'AI-influencer delivery aspect ratio.'}), + 'resolution_tier': (['2K', '4K'], {'default': '2K', 'tooltip': 'Exact longest side: 2048 or 4096 pixels.'}), } IMAGE_UPLOAD_FIELDS = () -INPUT_SOCKET_TYPES = {'image': 'IMAGE', 'grain_seed': 'INT', 'iso_preset': 'STRING', 'strength': 'FLOAT'} -REQUIRED_INPUT_NAMES = ('image',) -OUTPUT_SOCKET_TYPES = ('IMAGE',) +INPUT_SOCKET_TYPES = {'aspect_ratio': 'STRING', 'resolution_tier': 'STRING'} +REQUIRED_INPUT_NAMES = ('aspect_ratio', 'resolution_tier') +OUTPUT_SOCKET_TYPES = ('INT', 'INT') BATCH_POLICY = 'exactly-one' INPUT_IS_LIST = False -OUTPUT_IS_LIST = (False,) +OUTPUT_IS_LIST = (False, False) ROUTE_FIELD_NAMES = tuple(SCHEMA_WIDGETS) FRAMEWORK_FIELD_NAMES = () # Socket labels whose values carry a leading batch dimension. @@ -140,31 +139,28 @@ def _payload_items(inputs): for index in range(count) ) -class MATRIXRenoise: +class MATRIXLABAIInfluencerResolution2K4K: @classmethod def INPUT_TYPES(cls): return { "required": { - 'image': _input_widget('image'), + 'aspect_ratio': _input_widget('aspect_ratio'), + 'resolution_tier': _input_widget('resolution_tier'), }, "optional": { - 'grain_seed': _input_widget('grain_seed'), - 'iso_preset': _input_widget('iso_preset'), - 'strength': _input_widget('strength'), }, } - RETURN_TYPES = ('IMAGE',) - RETURN_NAMES = ('image',) + RETURN_TYPES = ('INT', 'INT') + RETURN_NAMES = ('width', 'height') FUNCTION = "execute" - CATEGORY = 'MATRIX LAB/Image Processing' - DESCRIPTION = 'Generated from operation block image.renoise.' + CATEGORY = 'MATRIX LAB/Resolution & Layout' + DESCRIPTION = 'Generated from operation block resolution.ai-influencer-2k4k.' INPUT_IS_LIST = INPUT_IS_LIST OUTPUT_IS_LIST = OUTPUT_IS_LIST async def execute(self, **inputs): inputs = _adapt_image_inputs(_fill_widget_defaults(inputs)) - from ..._core import image_renoise as _operation_block inputs['__flow_runtime__'] = { 'resolved_blocks': {OPERATION_BLOCK: _operation_block}, 'input_socket_types': INPUT_SOCKET_TYPES, @@ -177,5 +173,5 @@ async def execute(self, **inputs): } return await execute_compiled_node(NODE_ID, '', '', inputs) -NODE_CLASS_MAPPINGS = {NODE_ID: MATRIXRenoise} -NODE_DISPLAY_NAME_MAPPINGS = {NODE_ID: 'MATRIX RENOISE'} +NODE_CLASS_MAPPINGS = {NODE_ID: MATRIXLABAIInfluencerResolution2K4K} +NODE_DISPLAY_NAME_MAPPINGS = {NODE_ID: 'MATRIX AI INFLUENCER RESOLUTION 2K/4K'} diff --git a/pyproject.toml b/pyproject.toml index 8c9dbb2..92f6354 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "matrix-lab-nodes" -version = "0.1.0" +version = "0.2.0" description = "MATRIX LAB custom nodes for ComfyUI." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..6e62d9e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Public, offline distribution tests for MATRIX LAB NODES.""" diff --git a/tests/test_distribution.py b/tests/test_distribution.py new file mode 100644 index 0000000..fec761b --- /dev/null +++ b/tests/test_distribution.py @@ -0,0 +1,283 @@ +"""Acceptance tests that run from the standalone public repository. + +The suite intentionally uses only files shipped in the distribution. It does not +need the private Factory, retained runtime proof, ComfyUI, or provider access. +""" +from __future__ import annotations + +import asyncio +import hashlib +import importlib +import importlib.util +import inspect +import json +from pathlib import Path +import re +import socket +import sys +import unittest +from unittest import mock + +import torch + + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_NAME = "matrixlab_public_distribution_under_test" +RETIRED_NODE_IDS = {"MATRIX_CameraLook", "MATRIX_Renoise"} +BASE_NODE_IDS = { + "MATRIXLAB_AIInfluencerResolution", + "MATRIXLAB_EasyCrop", + "MATRIXLAB_ImageBatchLoader", + "MATRIXLAB_PromptDirector", + "MATRIXLAB_Resolution", + "MATRIXSpectralSampler", + "MATRIX_CropTailPaste", + "MATRIX_EyeMask", + "MATRIX_LatentTail", + "MATRIX_OutputStage", + "MATRIX_PhotoFinisher", + "MATRIX_SaveClean", + "MATRIX_SkinMask", +} +ADDITIVE_NODE_ID = "MATRIXLAB_AIInfluencerResolution2K4K" +NODE_DIRECTORIES = { + "image_processing", + "input_output", + "masks_detection", + "prompting", + "resolution_layout", + "sampling_detail", +} +PHOTO_DEFAULTS = { + "profile": "Everyday Capture", + "mix": 1.0, + "texture": 1.0, + "detail": 1.0, + "contrast": 0.0, + "warmth": 0.0, + "saturation": 1.0, + "seed": 42, +} + + +def _load_pack(): + """Import the checkout as a package even when its directory has a hyphen.""" + existing = sys.modules.get(PACKAGE_NAME) + if existing is not None: + return existing + spec = importlib.util.spec_from_file_location( + PACKAGE_NAME, + ROOT / "__init__.py", + submodule_search_locations=[str(ROOT)], + ) + if spec is None or spec.loader is None: + raise RuntimeError("could not create an import spec for the distribution") + module = importlib.util.module_from_spec(spec) + sys.modules[PACKAGE_NAME] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(PACKAGE_NAME, None) + raise + return module + + +class DistributionStructureTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.manifest = json.loads((ROOT / "MANIFEST.json").read_text(encoding="utf-8")) + cls.pack = _load_pack() + + def test_manifest_matches_imported_inventory_and_categories(self): + imported_ids = set(self.pack.NODE_CLASS_MAPPINGS) + self.assertIn(imported_ids, (BASE_NODE_IDS, BASE_NODE_IDS | {ADDITIVE_NODE_ID})) + self.assertEqual(imported_ids, set(self.manifest["nodes"])) + self.assertEqual(imported_ids, set(self.manifest["categories"])) + self.assertEqual(imported_ids, set(self.pack.NODE_DISPLAY_NAME_MAPPINGS)) + self.assertEqual(len(self.pack.NODE_CLASS_MAPPINGS), len(set(self.pack.NODE_CLASS_MAPPINGS.values()))) + for node_id, node_class in self.pack.NODE_CLASS_MAPPINGS.items(): + with self.subTest(node_id=node_id): + self.assertEqual(node_class.CATEGORY, self.manifest["categories"][node_id]) + + def test_package_and_manifest_versions_agree(self): + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"\s*$', pyproject, flags=re.MULTILINE) + self.assertIsNotNone(match, "pyproject.toml must declare a project version") + self.assertEqual(match.group(1), self.manifest["version"]) + + def test_distribution_has_exactly_the_six_public_node_directories(self): + actual = { + path.name + for path in (ROOT / "nodes").iterdir() + if path.is_dir() and path.name != "__pycache__" + } + self.assertEqual(actual, NODE_DIRECTORIES) + self.assertEqual({path.name for path in (ROOT / "nodes").glob("*.py")}, {"__init__.py"}) + + def test_retired_node_ids_are_not_registered_or_implemented(self): + visible_ids = ( + set(self.manifest["nodes"]) + | set(self.manifest["categories"]) + | set(self.pack.NODE_CLASS_MAPPINGS) + | set(self.pack.NODE_DISPLAY_NAME_MAPPINGS) + ) + self.assertTrue(RETIRED_NODE_IDS.isdisjoint(visible_ids)) + implementation = "\n".join( + path.read_text(encoding="utf-8") + for path in (ROOT / "nodes").rglob("*.py") + ) + for retired in RETIRED_NODE_IDS: + self.assertNotIn(retired, implementation) + + def test_runtime_and_font_asset_hashes_match(self): + runtime_registry = ROOT / "_core" / "runtime-assets.json" + self.assertEqual( + hashlib.sha256(runtime_registry.read_bytes()).hexdigest(), + self.manifest["runtime_asset_registry_sha256"], + ) + font_manifest = json.loads( + (ROOT / "web" / "assets" / "font-manifest.json").read_text(encoding="utf-8") + ) + assets = ROOT / "web" / "assets" + for file_key, hash_key in ( + ("font_file", "font_sha256"), + ("license_file", "license_sha256"), + ): + with self.subTest(asset=font_manifest[file_key]): + asset = assets / font_manifest[file_key] + self.assertTrue(asset.is_file()) + self.assertEqual(hashlib.sha256(asset.read_bytes()).hexdigest(), font_manifest[hash_key]) + license_text = (assets / font_manifest["license_file"]).read_text(encoding="utf-8") + self.assertEqual(font_manifest["license"], "OFL-1.1") + self.assertIn("SIL OPEN FONT LICENSE", license_text.upper()) + self.assertTrue((ROOT / "LICENSE").is_file()) + + def test_readme_local_links_resolve_inside_the_repository(self): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + links = re.findall(r"\[[^]]+\]\(([^)]+)\)", readme) + local_links = [link.split("#", 1)[0] for link in links if "://" not in link and not link.startswith("#")] + self.assertTrue(local_links, "README should include navigable local links") + root_resolved = ROOT.resolve() + for link in local_links: + with self.subTest(link=link): + target = (ROOT / link).resolve() + self.assertTrue(target == root_resolved or root_resolved in target.parents) + self.assertTrue(target.exists()) + + def test_example_has_consistent_unselected_input_and_connected_defaults(self): + graph = json.loads((ROOT / "examples/photo-finisher.json").read_text(encoding="utf-8")) + nodes = {node["id"]: node for node in graph["nodes"]} + self.assertEqual({node["type"] for node in nodes.values()}, + {"LoadImage", "MATRIX_PhotoFinisher", "PreviewImage"}) + for node in nodes.values(): + self.assertNotIn("widgets_values_named", node, "avoid stale parallel widget state") + if node["type"] == "LoadImage": + self.assertEqual(node["widgets_values"][0], "") + elif node["type"] == "MATRIX_PhotoFinisher": + self.assertEqual(node["widgets_values"], list(PHOTO_DEFAULTS.values())) + self.assertEqual(len(graph["links"]), 2) + for link_id, origin, origin_slot, target, target_slot, socket_type in graph["links"]: + self.assertIn(link_id, nodes[origin]["outputs"][origin_slot]["links"]) + self.assertEqual(nodes[target]["inputs"][target_slot]["link"], link_id) + self.assertEqual(socket_type, "IMAGE") + + +class PhotoFinisherTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.pack = _load_pack() + cls.node_class = cls.pack.NODE_CLASS_MAPPINGS["MATRIX_PhotoFinisher"] + cls.api = importlib.import_module(f"{PACKAGE_NAME}._core.image_photo_finisher") + + def test_public_defaults_are_stable_and_match_the_core_api(self): + optional = self.node_class.INPUT_TYPES()["optional"] + actual = {name: optional[name][1]["default"] for name in PHOTO_DEFAULTS} + self.assertEqual(actual, PHOTO_DEFAULTS) + parameters = inspect.signature(self.api.photo_finish).parameters + self.assertEqual({name: parameters[name].default for name in PHOTO_DEFAULTS}, PHOTO_DEFAULTS) + self.assertIs(optional["seed"][1]["control_after_generate"], False) + + def test_cpu_output_is_repeatable_batch_safe_and_preserves_alpha(self): + rgb = torch.linspace(0.1, 0.9, 2 * 17 * 19 * 3).reshape(2, 17, 19, 3) + alpha = torch.linspace(0.0, 1.0, 2 * 17 * 19).reshape(2, 17, 19, 1) + image = torch.cat((rgb, alpha), dim=-1) + before = image.clone() + first = self.api.photo_finish(image) + second = self.api.photo_finish(image) + self.assertTrue(torch.equal(first, second)) + self.assertTrue(torch.equal(image, before)) + self.assertTrue(torch.equal(first[..., 3], image[..., 3])) + self.assertFalse(torch.equal(first[..., :3], image[..., :3])) + self.assertEqual(first.shape, image.shape) + self.assertEqual(first.device.type, "cpu") + + def test_zero_saturation_is_grayscale_with_default_texture(self): + image = torch.rand(2, 17, 19, 3, generator=torch.Generator().manual_seed(71)) + for profile in self.api.PROFILES: + with self.subTest(profile=profile): + result = self.api.photo_finish(image, profile=profile, saturation=0.0) + self.assertTrue(torch.equal(result[..., 0], result[..., 1])) + self.assertTrue(torch.equal(result[..., 1], result[..., 2])) + + def test_mix_zero_is_exact_identity(self): + image = torch.rand(2, 13, 11, 3) + result = self.api.photo_finish(image, mix=0.0) + self.assertIs(result, image) + self.assertTrue(torch.equal(result, image)) + + def test_mask_zero_preserves_and_soft_mask_blends(self): + image = torch.linspace(0.1, 0.9, 15 * 21 * 3).reshape(1, 15, 21, 3) + full = self.api.photo_finish(image, seed=17) + mask = torch.full((1, 15, 21), 0.5) + mask[:, :, 0] = 0.0 + masked = self.api.photo_finish(image, mask, seed=17) + self.assertTrue(torch.equal(masked[:, :, 0], image[:, :, 0])) + torch.testing.assert_close( + masked[:, :, 1:], + (image[:, :, 1:] + full[:, :, 1:]) / 2, + rtol=0.0, + atol=2e-7, + ) + + def test_compiled_node_executes_actual_cpu_operation(self): + image = torch.full((2, 9, 7, 3), 0.45) + result, = asyncio.run(self.node_class().execute(image=image)) + expected = self.api.photo_finish(image) + self.assertTrue(torch.equal(result, expected)) + + +class OfflineNodeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.pack = _load_pack() + + def test_prompt_director_manual_execution_returns_saved_text(self): + node = self.pack.NODE_CLASS_MAPPINGS["MATRIXLAB_PromptDirector"]() + saved = " a manually edited prompt; no provider call " + with mock.patch.object(socket.socket, "connect", side_effect=AssertionError("network attempted")): + self.assertEqual(node.execute(final_prompt=saved), (saved,)) + self.assertEqual(node.check_lazy_status(), []) + + def test_additive_resolution_node_returns_all_six_dimensions_when_present(self): + node_id = ADDITIVE_NODE_ID + if node_id not in self.pack.NODE_CLASS_MAPPINGS: + self.skipTest("base distribution does not include the additive 2K/4K node") + node = self.pack.NODE_CLASS_MAPPINGS[node_id]() + expected = { + ("1:1", "2K"): (2048, 2048), + ("1:1", "4K"): (4096, 4096), + ("9:16", "2K"): (1152, 2048), + ("9:16", "4K"): (2304, 4096), + ("3:4", "2K"): (1536, 2048), + ("3:4", "4K"): (3072, 4096), + } + for (ratio, tier), dimensions in expected.items(): + with self.subTest(ratio=ratio, tier=tier): + self.assertEqual( + asyncio.run(node.execute(aspect_ratio=ratio, resolution_tier=tier)), + dimensions, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/ai_influencer_resolution.ebb32f136a6a4294.js b/web/ai_influencer_resolution.bd473fd081a9ee32.js similarity index 99% rename from web/ai_influencer_resolution.ebb32f136a6a4294.js rename to web/ai_influencer_resolution.bd473fd081a9ee32.js index 3a8ecb6..4837df8 100644 --- a/web/ai_influencer_resolution.ebb32f136a6a4294.js +++ b/web/ai_influencer_resolution.bd473fd081a9ee32.js @@ -1,5 +1,5 @@ import { app } from "../../scripts/app.js"; -import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, setHaloNodeSize, calculateTierResolution, migrateResolutionGraph } from "./halo_resolution.f5554171d9d043a1.mjs"; +import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, setHaloNodeSize, calculateTierResolution, migrateResolutionGraph } from "./halo_resolution.3078c6fb7c5009a2.mjs"; const ASPECT_RATIOS = ["1:1", "9:16", "3:4"]; const RESOLUTION_TIERS = ["1K", "2K", "4K"]; export function calculateResolution(state) { diff --git a/web/ai_influencer_resolution_2k4k.5a6f0a9d0be56983.js b/web/ai_influencer_resolution_2k4k.5a6f0a9d0be56983.js new file mode 100644 index 0000000..a439d3a --- /dev/null +++ b/web/ai_influencer_resolution_2k4k.5a6f0a9d0be56983.js @@ -0,0 +1,319 @@ +import { app } from "../../scripts/app.js"; +import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, setHaloNodeSize, calculateTierResolution } from "./halo_resolution.3078c6fb7c5009a2.mjs"; +const ASPECT_RATIOS = ["1:1", "9:16", "3:4"]; +const RESOLUTION_TIERS = ["2K", "4K"]; +export function calculateResolution(state) { + if (!ASPECT_RATIOS.includes(state.aspect_ratio)) throw new Error("Unsupported aspect ratio"); + return calculateTierResolution(state.aspect_ratio, state.resolution_tier); +} + +const NODE_IDS = new Set( + "MATRIXLAB_AIInfluencerResolution2K4K".split(",").filter(Boolean), +); +const DEFAULT_STATE = Object.freeze({ aspect_ratio: "3:4", resolution_tier: "2K" }); +const MIN_SURFACE_WIDTH = 420; +const CONTENT_HEIGHT = 460; + +export const BUTTONS = Object.freeze([ + ...ASPECT_RATIOS.map((value) => ({ group: "aspect_ratio", value, label: value })), + ...RESOLUTION_TIERS.map((value) => ({ + group: "resolution_tier", + value, + label: value, + })), +]); + +export function applyButton(state, group, value) { + if (!BUTTONS.some((button) => button.group === group && button.value === value)) { + throw new Error(`Unsupported resolution control: ${group}=${value}`); + } + return { ...state, [group]: value }; +} + +export const CONTROL = Symbol.for("matrix.ai-influencer-resolution-2k4k.control"); + +function widgetsByName(node) { + return Object.fromEntries((node.widgets || []).map((widget) => [widget.name, widget])); +} + +function readState(node) { + const widgets = widgetsByName(node); + return { + aspect_ratio: widgets.aspect_ratio?.value ?? DEFAULT_STATE.aspect_ratio, + resolution_tier: widgets.resolution_tier?.value ?? DEFAULT_STATE.resolution_tier, + }; +} + +function isWidgetLinked(node, name) { + return (node.inputs || []).some((input) => + (input?.widget?.name === name || input?.name === name) + && input.link !== null + && input.link !== undefined + ); +} + +function hideCanonicalWidgets(node, control) { + const names = new Set(Object.keys(DEFAULT_STATE)); + for (const widget of node.widgets || []) { + if (!names.has(widget.name)) continue; + const hadOptions = Boolean(widget.options); + widget.options ||= {}; + const entry = { + widget, + draw: widget.draw, + computeSize: widget.computeSize, + callback: widget.callback, + hidden: widget.options.hidden, + hadOptions, + }; + const previousCallback = widget.callback; + entry.watchedCallback = function () { + try { + return previousCallback?.apply(this, arguments); + } finally { + control.render?.(); + } + }; + widget.draw = () => {}; + widget.computeSize = () => [0, -4]; + widget.options.hidden = true; + widget.callback = entry.watchedCallback; + control.widgetPresentation.push(entry); + } +} + +function restoreCanonicalWidgets(control) { + for (const entry of control.widgetPresentation) { + entry.widget.draw = entry.draw; + entry.widget.computeSize = entry.computeSize; + if (entry.widget.callback === entry.watchedCallback) entry.widget.callback = entry.callback; + if (entry.hidden === undefined) delete entry.widget.options.hidden; + else entry.widget.options.hidden = entry.hidden; + if (!entry.hadOptions && Object.keys(entry.widget.options).length === 0) { + delete entry.widget.options; + } + } + control.widgetPresentation.length = 0; +} + +function createHaloPanel(node, control) { + return createHaloResolutionDeck({ + document, + node, + control, + ariaLabel: "AI Influencer resolution 2K/4K controls", + haloOptions: { app }, + groups: [ + { + id: "aspect_ratio", + label: "ASPECT", + items: BUTTONS.filter((item) => item.group === "aspect_ratio").map((item) => ({ + id: `${item.group}:${item.value}`, + label: item.label, + ratio: item.value.split(":").map(Number), + group: item.group, + value: item.value, + })), + }, + { + id: "resolution_tier", + label: "SIZE", + items: BUTTONS.filter((item) => item.group === "resolution_tier").map((item) => ({ + id: `${item.group}:${item.value}`, + label: item.label, + group: item.group, + value: item.value, + })), + }, + ], + activate(action, event) { + const separator = action.indexOf(":"); + return control.activate(action.slice(0, separator), action.slice(separator + 1), event); + }, + isDisabled(action) { + const group = action.slice(0, action.indexOf(":")); + return isWidgetLinked(node, group); + }, + disabledReason(action) { + return `${action.slice(0, action.indexOf(":"))} is controlled by a connected input.`; + }, + view() { + const state = readState(node); + try { + const dimensions = calculateResolution(state); + return { + tier: state.resolution_tier.toUpperCase(), + profile: "", + active: { + aspect_ratio: `aspect_ratio:${state.aspect_ratio}`, + resolution_tier: `resolution_tier:${state.resolution_tier}`, + }, + dimensions, + ratioLabel: state.aspect_ratio, + method: "", + error: control.errorMessage || "", + }; + } catch (problem) { + return { + tier: "INVALID", + profile: "", + active: {}, + dimensions: [0, 0], + ratioLabel: state.aspect_ratio, + method: "", + error: control.errorMessage || String(problem?.message || problem), + }; + } + }, + }); +} + +function install(node) { + if (node[CONTROL]) return node[CONTROL]; + if (typeof document === "undefined" || typeof node.addDOMWidget !== "function") return null; + const control = { + destroyed: false, + enforcingMinimumSize: false, + horizontalChrome: null, + resizeObserver: null, + resizeTimer: null, + previousResize: null, + watchedResize: null, + widgetPresentation: [], + activate(group, value, event) { + if (this.destroyed) return false; + if (isWidgetLinked(node, group)) { + this.errorMessage = `${group} is controlled by a connected input.`; + this.render?.(); + return false; + } + try { + const next = applyButton(readState(node), group, value); + const widget = widgetsByName(node)[group]; + if (!widget) throw new Error(`Missing canonical widget: ${group}`); + widget.value = next[group]; + widget.callback?.call(widget, next[group], app.canvas, node, node.pos, event); + this.errorMessage = ""; + node.setDirtyCanvas?.(true, true); + this.render(); + return true; + } catch (problem) { + this.errorMessage = String(problem?.message || problem); + this.render?.(); + return false; + } + }, + }; + const existingWidgets = new Set(node.widgets || []); + let element; + let host; + let domWidget; + try { + element = createHaloPanel(node, control); + host = createHaloWidgetHost(element, document); + if (!host) throw new Error("AI Influencer Resolution 2K/4K HALO host unavailable"); + control.host = host; + domWidget = node.addDOMWidget("matrixlab_ai_influencer_resolution_2k4k_ui", "matrixlab-prism", host, { + serialize: false, + hideOnZoom: false, + getMinHeight: () => haloWidgetLayoutHeight(measureHaloContentHeight(element, control.minimumContentHeight || CONTENT_HEIGHT), domWidget), + getHeight: () => haloWidgetLayoutHeight(measureHaloContentHeight(element, control.minimumContentHeight || CONTENT_HEIGHT), domWidget), + }); + if (!domWidget) throw new Error("ComfyUI did not create the DOM widget"); + domWidget.serialize = false; + domWidget.options ||= {}; + domWidget.options.serialize = false; + if (!control.mountHalo?.()) throw new Error("AI Influencer Resolution 2K/4K HALO surface unavailable"); + hideCanonicalWidgets(node, control); + node[CONTROL] = control; + } catch (problem) { + control.destroyed = true; + control.halo?.destroy(); + restoreCanonicalWidgets(control); + if (Array.isArray(node.widgets)) { + for (let index = node.widgets.length - 1; index >= 0; index -= 1) { + const widget = node.widgets[index]; + if (!existingWidgets.has(widget) + && (widget === domWidget || widget?.element === host || widget?.name === "matrixlab_ai_influencer_resolution_2k4k_ui")) { + node.widgets.splice(index, 1); + } + } + } + element?.remove(); + host?.remove(); + console.warn("AI Influencer Resolution 2K/4K UI unavailable; native widgets remain active", problem); + return null; + } + + const ensureMinimumSize = () => { + if (control.destroyed || control.enforcingMinimumSize) return; + const currentWidth = Number(node.size?.[0]) || 0; + const currentHeight = Number(node.size?.[1]) || 0; + if (control.horizontalChrome == null) control.horizontalChrome = measureHaloHorizontalChrome(element, currentWidth); + const width = Math.max(currentWidth, MIN_SURFACE_WIDTH + (control.horizontalChrome ?? 0)); + const contentHeight = measureHaloContentHeight(element, control.minimumContentHeight || CONTENT_HEIGHT); + const height = haloMinimumNodeHeight(node, contentHeight); + if (width === currentWidth && height === currentHeight) return; + const size = [width, height]; + control.enforcingMinimumSize = true; + try { + setHaloNodeSize(node, size); + } finally { + control.enforcingMinimumSize = false; + } + }; + const scheduleMinimumSize = () => { + clearTimeout(control.resizeTimer); + control.resizeTimer = setTimeout(() => { + control.resizeTimer = null; + ensureMinimumSize(); + control.render(); + }, 0); + }; + control.scheduleMinimumSize = scheduleMinimumSize; + control.previousResize = node.onResize; + control.watchedResize = function () { + const result = control.previousResize?.apply(this, arguments); + if (!control.enforcingMinimumSize) scheduleMinimumSize(); + control.render(); + return result; + }; + node.onResize = control.watchedResize; + if (typeof ResizeObserver === "function") { + control.resizeObserver = new ResizeObserver(() => { + control.scheduleMinimumSize?.(); + control.render(); + }); + control.resizeObserver.observe(element); + } + + const previousRemoved = node.onRemoved; + node.onRemoved = function () { + control.destroyed = true; + clearTimeout(control.resizeTimer); + control.resizeObserver?.disconnect(); + control.halo?.destroy(); + restoreCanonicalWidgets(control); + if (this.onResize === control.watchedResize) this.onResize = control.previousResize; + if (this[CONTROL] === control) delete this[CONTROL]; + element?.remove(); + host?.remove(); + return previousRemoved?.apply(this, arguments); + }; + + node.resizable = true; + ensureMinimumSize(); + control.render(); + node.setDirtyCanvas?.(true, true); + return control; +} + +app.registerExtension({ + name: "matrix.ai-influencer-resolution-2k4k.halo", + nodeCreated(node) { + if (NODE_IDS.has(node.comfyClass || node.type)) install(node); + }, + loadedGraphNode(node) { + if (NODE_IDS.has(node.comfyClass || node.type)) install(node); + }, +}); diff --git a/web/easy_crop.3cd2d4db9e4387a9.js b/web/easy_crop.e448d80a597cbf32.js similarity index 99% rename from web/easy_crop.3cd2d4db9e4387a9.js rename to web/easy_crop.e448d80a597cbf32.js index 7fecb64..ee875a4 100644 --- a/web/easy_crop.3cd2d4db9e4387a9.js +++ b/web/easy_crop.e448d80a597cbf32.js @@ -1,6 +1,6 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -import { createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, mountHaloSurface, setHaloNodeSize } from "./halo.640a7993d6ac30a9.mjs"; +import { createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, mountHaloSurface, setHaloNodeSize } from "./halo.3bc35993e091e5c5.mjs"; const NODE_IDS = new Set(["MATRIXLAB_EasyCrop"]); export const CONTROL = Symbol.for("matrixlab.easy-crop.control"); diff --git a/web/gallery.f348860f4f6a4200.mjs b/web/gallery.e7ba3b92bd70c1e5.mjs similarity index 99% rename from web/gallery.f348860f4f6a4200.mjs rename to web/gallery.e7ba3b92bd70c1e5.mjs index ae2f1bd..cda54e2 100644 --- a/web/gallery.f348860f4f6a4200.mjs +++ b/web/gallery.e7ba3b92bd70c1e5.mjs @@ -1,4 +1,4 @@ -import { haloMinimumNodeHeight, measureHaloContentHeight, mountHaloSurface, setHaloNodeSize } from "./halo.640a7993d6ac30a9.mjs"; +import { haloMinimumNodeHeight, measureHaloContentHeight, mountHaloSurface, setHaloNodeSize } from "./halo.3bc35993e091e5c5.mjs"; export const GALLERY_STATE_VERSION = 1; export const MAX_GALLERY_IMAGES = 10; diff --git a/web/halo.640a7993d6ac30a9.mjs b/web/halo.3bc35993e091e5c5.mjs similarity index 94% rename from web/halo.640a7993d6ac30a9.mjs rename to web/halo.3bc35993e091e5c5.mjs index 97c8ba1..448ab33 100644 --- a/web/halo.640a7993d6ac30a9.mjs +++ b/web/halo.3bc35993e091e5c5.mjs @@ -1,4 +1,4 @@ -export const HALO_VERSION = "1.0.0"; +export const HALO_VERSION = "1.0.1"; export const HALO_MOTION_SETTING_ID = "MATRIXLAB.HALO.Motion"; export const HALO_GLYPHS = @@ -12,8 +12,7 @@ export const HALO_EXECUTION_NODE_IDS = Object.freeze([ "MATRIX_CropTailPaste", "MATRIX_SaveClean", "MATRIX_OutputStage", - "MATRIX_Renoise", - "MATRIX_CameraLook", + "MATRIX_PhotoFinisher", ]); function numericStyle(value) { @@ -146,6 +145,14 @@ export function setHaloNodeSize(node, size) { export function measureHaloHorizontalChrome(root, currentWidth, suppliedChrome) { const ownedWidth = Number(root?.clientWidth || root?.offsetWidth); if (!(ownedWidth > 0)) return null; + const view = root.ownerDocument?.defaultView || globalThis; + const style = view.getComputedStyle?.(root); + const padding = numericStyle(style?.paddingLeft) + numericStyle(style?.paddingRight); + const border = root.clientWidth ? 0 + : numericStyle(style?.borderLeftWidth) + numericStyle(style?.borderRightWidth); + // A collapsed host can leave only the surface's padding measurable. That is + // not a laid-out widget slot and must not be cached as renderer chrome. + if (!(ownedWidth - padding - border > 0)) return null; const measured = Math.max(0, (Number(currentWidth) || 0) - ownedWidth); const supplied = typeof suppliedChrome === "object" ? Number(suppliedChrome?.horizontal) @@ -496,7 +503,7 @@ function installStyle(doc) { .matrixlab-halo-select{appearance:auto;box-sizing:border-box;min-width:0;max-width:100%;border:1px solid ${HALO_TOKENS.fieldBorder};border-radius:6px;background:${HALO_TOKENS.linkedSurface};color:${HALO_TOKENS.primaryText};color-scheme:dark;accent-color:${HALO_TOKENS.green};direction:ltr;text-align:left;text-align-last:left} .matrixlab-halo-select option{background:${HALO_TOKENS.linkedSurface};color:${HALO_TOKENS.primaryText};direction:ltr;text-align:left} .matrixlab-halo__field input{box-sizing:border-box;min-width:0;width:108px;flex:0 1 108px;max-width:100%;min-height:24px;border:1px solid ${HALO_TOKENS.fieldBorder};border-radius:6px;background:${HALO_TOKENS.linkedSurface};color:${HALO_TOKENS.primaryText};text-align:right} -.matrixlab-halo__field .matrixlab-halo-select{width:108px;flex:0 1 108px;min-height:24px} +.matrixlab-halo__field .matrixlab-halo-select{width:152px;flex:0 1 152px;min-height:24px} .matrixlab-halo__field input[type="checkbox"]{width:18px;flex:0 0 18px} .matrixlab-halo__field input:focus-visible,.matrixlab-halo-select:focus-visible,.matrixlab-halo__field button:focus-visible{outline:2px solid ${HALO_TOKENS.focus};outline-offset:4px} .matrixlab-halo__field input:disabled,.matrixlab-halo-select:disabled{color:${HALO_TOKENS.linkedText};opacity:1} @@ -1152,12 +1159,26 @@ function parameterFieldPresentation(node, widget) { : "" }; } +const CONTROL_ID_STATE_KEY = Symbol.for("matrixlab.halo.control-id-state"); + +function allocateControlId(doc) { + let state = doc[CONTROL_ID_STATE_KEY]; + if (!state || typeof state !== "object") { + state = { next: 0 }; + Object.defineProperty(doc, CONTROL_ID_STATE_KEY, { value: state, configurable: true }); + } + state.next += 1; + return `matrixlab-halo-control-${state.next}`; +} + function createWidgetField(doc, node, widget, options) { const field = doc.createElement("div"); field.className = "matrixlab-halo__field"; const label = doc.createElement("label"); label.textContent = widget.label || widget.name; - const inputId = `matrixlab-halo-${node.id ?? "node"}-${widget.name}`; + // Nodes can mount before ComfyUI replaces their shared temporary id (-1). Keep + // presentation identity document-local and shared across independently loaded packs. + const inputId = allocateControlId(doc); label.htmlFor = inputId; let control; const choices = () => { @@ -1220,10 +1241,23 @@ function createWidgetField(doc, node, widget, options) { const result = coerceValue(widget, raw); if (!result.accepted) { render(); return; } const next = result.value; - widget.value = next; + if (Object.is(next, widget.value)) { render(); return; } const position = options.getCallbackPosition?.(event, node) ?? node.pos; - widget.callback?.call(widget, next, options.app?.canvas || options.canvas || null, node, position, event); - render(); + const graph = node.graph; + const canvas = options.app?.canvas || options.canvas || null; + graph?.beforeChange?.(); + // ComfyUI history listens to the canvas events, independently of graph hooks. + canvas?.emitBeforeChange?.(); + try { + widget.value = next; + widget.callback?.call(widget, next, canvas, node, position, event); + } finally { + try { render(); } + finally { + try { graph?.afterChange?.(); } + finally { canvas?.emitAfterChange?.(); } + } + } }; control.addEventListener("change", commit); const marker = doc.createElement("span"); @@ -1346,7 +1380,11 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, root.className = "matrixlab-halo__content"; const host = createHaloWidgetHost(root, doc); if (!host) return null; - const presentationName = profile === "execution" ? "matrixlab_halo_execution_ui" : "matrixlab_halo_utility_ui"; + // BaseWidget.widgetId is getter-only and derives from graph/node/widget name. + // A fresh non-serializing name therefore gives each reconstruction a supported + // Vue render identity without mutating ComfyUI's widget object. + const presentationBaseName = profile === "execution" ? "matrixlab_halo_execution_ui" : "matrixlab_halo_utility_ui"; + const presentationName = `${presentationBaseName}_${allocateControlId(doc)}`; const fields = canonicalWidgets.filter((widget) => widget?.name).map((widget) => createWidgetField(doc, node, widget, options)); for (const field of fields) root.appendChild(field.field); const savedImages = nodeType === "MATRIX_SaveClean" ? mountHaloSavedImages(node, root, options) : null; @@ -1357,6 +1395,8 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, let presentation = null; let halo = null; let destroyed = false; + let presentationRemoval = null; + let presentationCleanupPending = false; let resizeTimer = null; let sizingObserver = null; let previousResize = node.onResize; @@ -1375,6 +1415,11 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, } } }; + const unregisterPresentation = () => { + if (!presentationCleanupPending) return; + presentationCleanupPending = false; + presentation?.onRemove?.(); + }; const rollback = () => { clearTimeout(resizeTimer); sizingObserver?.disconnect(); @@ -1398,6 +1443,10 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, getHeight: () => haloWidgetLayoutHeight(contentMinimum(), presentation), afterResize: () => fields.forEach((field) => field.render()), }); + // addDOMWidget installs its unregister callback in node.onRemoved. Capture it + // before checking the result or performing any other fallible setup. + presentationRemoval = node.onRemoved; + presentationCleanupPending = Boolean(presentation); if (!presentation) throw new Error("ComfyUI did not create the HALO DOM widget"); presentation.serialize = false; presentation.options ||= {}; @@ -1406,7 +1455,9 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, if (!halo) throw new Error("HALO surface mount failed"); hideWidgets(snapshots); } catch (error) { + unregisterPresentation(); rollback(); + if (node.onRemoved === presentationRemoval) node.onRemoved = previousRemoved; options.onError?.(error); return null; } @@ -1426,7 +1477,7 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, }); } const horizontalChrome = measuredHorizontalChrome ?? 0; - const width = Math.max( + const width = measuredHorizontalChrome == null ? currentWidth : Math.max( currentWidth, minimumWidth + horizontalChrome, ); @@ -1496,9 +1547,10 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, halo, canonicalWidgets, render: () => fields.forEach((field) => field.render()), - destroy() { + destroy(fromNodeRemoval = false) { if (destroyed) return; destroyed = true; + if (!fromNodeRemoval) unregisterPresentation(); rollback(); if (node.onResize === wrappedResize) node.onResize = previousResize; if (node.onRemoved === wrappedRemoved) node.onRemoved = previousRemoved; @@ -1507,8 +1559,12 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, }; node[EXECUTION_KEY] = control; wrappedRemoved = function (...args) { - control.destroy(); - return previousRemoved?.apply(this, args); + control.destroy(true); + try { + return (presentationRemoval || previousRemoved)?.apply(this, args); + } finally { + presentationCleanupPending = false; + } }; node.onRemoved = wrappedRemoved; scheduleMinimum(); @@ -1522,3 +1578,10 @@ export function mountHaloExecutionNode(node, options = {}) { export function mountHaloUtilityNode(node, options = {}) { return mountHaloPrimitiveNode(node, options, UTILITY_IDS, "ui"); } + +// Generated MATRIX profiles pass their exact declaration IDs; unrelated nodes stay untouched. +export function mountHaloParameterNode(node, nodeIds = [], options = {}) { + if (!Array.isArray(nodeIds) || !nodeIds.length || + nodeIds.some(id => typeof id !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(id))) return null; + return mountHaloPrimitiveNode(node, options, new Set(nodeIds), "execution"); +} diff --git a/web/halo_execution.41a5414872870fdf.js b/web/halo_execution.41a5414872870fdf.js deleted file mode 100644 index d90db2d..0000000 --- a/web/halo_execution.41a5414872870fdf.js +++ /dev/null @@ -1,10 +0,0 @@ -import { app } from "../../scripts/app.js"; -import { mountHaloExecutionNode } from "./halo.640a7993d6ac30a9.mjs"; -const ids = new Set(["MATRIXSpectralSampler", "MATRIX_CameraLook", "MATRIX_CropTailPaste", "MATRIX_EyeMask", "MATRIX_LatentTail", "MATRIX_OutputStage", "MATRIX_Renoise", "MATRIX_SaveClean", "MATRIX_SkinMask"]); -const attach = (node) => { - if (ids.has(node.comfyClass || node.type)) mountHaloExecutionNode(node, {app}); -}; -app.registerExtension({ - name: "matrixlab.halo.execution.fcdefaebf9909c7c", - nodeCreated: attach, loadedGraphNode: attach, -}); diff --git a/web/halo_execution.9c05a384226d4180.js b/web/halo_execution.9c05a384226d4180.js new file mode 100644 index 0000000..3c798de --- /dev/null +++ b/web/halo_execution.9c05a384226d4180.js @@ -0,0 +1,10 @@ +import { app } from "../../scripts/app.js"; +import { mountHaloExecutionNode } from "./halo.3bc35993e091e5c5.mjs"; +const ids = new Set(["MATRIXSpectralSampler", "MATRIX_CropTailPaste", "MATRIX_EyeMask", "MATRIX_LatentTail", "MATRIX_OutputStage", "MATRIX_PhotoFinisher", "MATRIX_SaveClean", "MATRIX_SkinMask"]); +const attach = (node) => { + if (ids.has(node.comfyClass || node.type)) mountHaloExecutionNode(node, {app}); +}; +app.registerExtension({ + name: "matrixlab.halo.execution.d5bdc09336581bf5", + nodeCreated: attach, loadedGraphNode: attach, +}); diff --git a/web/halo_resolution.f5554171d9d043a1.mjs b/web/halo_resolution.3078c6fb7c5009a2.mjs similarity index 99% rename from web/halo_resolution.f5554171d9d043a1.mjs rename to web/halo_resolution.3078c6fb7c5009a2.mjs index d6c7b4b..871f38e 100644 --- a/web/halo_resolution.f5554171d9d043a1.mjs +++ b/web/halo_resolution.3078c6fb7c5009a2.mjs @@ -1,4 +1,4 @@ -import { HALO_TOKENS, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, measureHaloVerticalChrome, mountHaloSurface, setHaloNodeSize } from "./halo.640a7993d6ac30a9.mjs"; +import { HALO_TOKENS, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, measureHaloVerticalChrome, mountHaloSurface, setHaloNodeSize } from "./halo.3bc35993e091e5c5.mjs"; export { createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, measureHaloVerticalChrome, setHaloNodeSize }; diff --git a/web/image_collection.8677fd9cc9c550ba.js b/web/image_collection.57c26c1112198635.js similarity index 95% rename from web/image_collection.8677fd9cc9c550ba.js rename to web/image_collection.57c26c1112198635.js index 4b67185..7ab5846 100644 --- a/web/image_collection.8677fd9cc9c550ba.js +++ b/web/image_collection.57c26c1112198635.js @@ -1,5 +1,5 @@ -import { mountImageGallery } from "./gallery.f348860f4f6a4200.mjs"; -import { createHaloWidgetHost, haloWidgetLayoutHeight, measureHaloContentHeight, setHaloNodeSize } from "./halo.640a7993d6ac30a9.mjs"; +import { mountImageGallery } from "./gallery.e7ba3b92bd70c1e5.mjs"; +import { createHaloWidgetHost, haloWidgetLayoutHeight, measureHaloContentHeight, setHaloNodeSize } from "./halo.3bc35993e091e5c5.mjs"; const { app } = globalThis.comfyAPI?.app || {}; const { api } = globalThis.comfyAPI?.api || {}; @@ -39,7 +39,7 @@ function restoreWidget(snapshot) { const { widget } = snapshot; widget.draw = snapshot.draw; widget.computeSize = snapshot.computeSize; - widget.callback = snapshot.callback; + if (widget.callback === snapshot.ownedCallback) widget.callback = snapshot.callback; if (snapshot.hadOptions) { widget.options = snapshot.options; if (widget.options) widget.options.hidden = snapshot.hidden; @@ -160,13 +160,14 @@ function install(node) { document, }); if (!gallery) throw new Error("Image collection gallery mount failed"); - collection.callback = function (...args) { + snapshot.ownedCallback = function (...args) { try { return snapshot.callback?.apply(this, args); } finally { control.syncFromWidget(collection.value); } }; + collection.callback = snapshot.ownedCallback; hideWidget(snapshot); node[CONTROL] = control; node.onResize = wrappedResize; diff --git a/web/prompt_director.b4c0409b1a3bd876.js b/web/prompt_director.47d51c1218485744.js similarity index 99% rename from web/prompt_director.b4c0409b1a3bd876.js rename to web/prompt_director.47d51c1218485744.js index f4e2301..334a2fb 100644 --- a/web/prompt_director.b4c0409b1a3bd876.js +++ b/web/prompt_director.47d51c1218485744.js @@ -6,7 +6,7 @@ import { measureHaloContentHeight, mountHaloSurface, setHaloNodeSize, -} from "./halo.640a7993d6ac30a9.mjs"; +} from "./halo.3bc35993e091e5c5.mjs"; const { app } = globalThis.comfyAPI?.app || {}; const { api } = globalThis.comfyAPI?.api || {}; @@ -607,7 +607,7 @@ export function mountPromptDirector(node, options = {}) { !credentialReady || !modelReady; recover.hidden = !state.request_id || !["submitting", "pending", "indeterminate"].includes(state.state); recover.disabled = busy || isWidgetLinked(node, "final_prompt") || isWidgetLinked(node, "generation_state"); - status.dataset.error = String(Boolean(collectionError || actionError || state.state === "indeterminate")); + status.dataset.error = String(Boolean(collectionError || actionError || ["failed", "indeterminate"].includes(state.state))); status.dataset.stale = String(state.state === "stale"); if (collectionError) status.textContent = collectionError; else if (credentialError) status.textContent = credentialError; @@ -616,6 +616,7 @@ export function mountPromptDirector(node, options = {}) { else if (busy) status.textContent = pending.kind === "recover" ? "Checking saved request…" : "Generating prompt…"; else if (state.state === "complete") status.textContent = state.manual_edit_preserved ? "Complete · manual prompt preserved" : "Prompt ready"; else if (state.state === "stale") status.textContent = state.reason || "Prompt is stale; generate when ready."; + else if (state.state === "failed") status.textContent = state.error || "The provider definitively rejected or could not complete the request."; else if (state.state === "indeterminate") status.textContent = state.error || "Request outcome is uncertain. Recover it explicitly."; else if (state.state === "pending" || state.state === "submitting") status.textContent = "Request may still be pending. Recover it explicitly."; else status.textContent = "A normal graph run reuses the saved final prompt."; @@ -981,6 +982,10 @@ export function mountPromptDirector(node, options = {}) { else if (["pending", "claimed", "submitted"].includes(payload?.state)) { writeState({ version: 1, request_id: requestId, state: "pending", ...requestMeta }, event); } + else if (payload?.state === "failed") { + writeState({ version: 1, request_id: requestId, state: "failed", ...requestMeta, + error: typeof payload?.error === "string" ? payload.error : "Request failed definitively." }, event); + } else writeState({ version: 1, request_id: requestId, state: "indeterminate", ...requestMeta, error: typeof payload?.error === "string" ? payload.error : typeof payload?.message === "string" ? payload.message : "Request outcome is indeterminate." }, event); diff --git a/web/resolution_control_deck.355f138d9de085d8.js b/web/resolution_control_deck.83ec2d2da93e1ef9.js similarity index 99% rename from web/resolution_control_deck.355f138d9de085d8.js rename to web/resolution_control_deck.83ec2d2da93e1ef9.js index e9db3d6..ace213f 100644 --- a/web/resolution_control_deck.355f138d9de085d8.js +++ b/web/resolution_control_deck.83ec2d2da93e1ef9.js @@ -1,5 +1,5 @@ import { app } from "../../scripts/app.js"; -import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, setHaloNodeSize, calculateTierResolution, migrateResolutionGraph } from "./halo_resolution.f5554171d9d043a1.mjs"; +import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, setHaloNodeSize, calculateTierResolution, migrateResolutionGraph } from "./halo_resolution.3078c6fb7c5009a2.mjs"; const NODE_IDS = new Set("MATRIXLAB_Resolution".split(",").filter(Boolean)); const RATIOS = {