Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Installation contract for AI agents

Install MATRIX LAB NODES package version 0.3.0 as one ComfyUI custom-node pack. Treat the installed `MANIFEST.json` as the exact class inventory.
Install MATRIX LAB NODES package version 0.3.1 as one ComfyUI custom-node pack. Treat the installed `MANIFEST.json` as the exact class inventory.

## Hard boundaries

Expand All @@ -19,7 +19,7 @@ Install MATRIX LAB NODES package version 0.3.0 as one ComfyUI custom-node pack.
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.3.0`, fourteen unique class IDs, and six category groups.
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.3.1`, 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`.
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

User-visible changes are recorded here. This file describes package content; publication tags and release channels are separate facts.

## 0.3.1 - development candidate - 2026-09-09

- Metadata Killer displays a small, metadata-verified preview while keeping the verified full-resolution original for opening and downloading.
- Added a contained original-image dialog and preview retry without saving another output file.
- Simplified controls to Name and Export with JPEG quality presets, PNG lossless output and persistent custom quality. Existing filename, format and quality inputs and defaults remain compatible.
- Preserve PNG alpha; reject JPEG alpha with a clear explanation. Validate portable filenames and avoid overwriting outputs when concurrent saves select the same counter.
- Report files already saved when a batch fails or is interrupted, and reject empty image tensors clearly.

Local Metadata Killer checks cover Classic and Nodes 2.0 on the recorded test environment. This update does not claim complete live acceptance of every node or cloud environment.

## 0.3.0 - development candidate - 2026-09-09

- Standardized eight public class IDs: `MATRIX_MetadataKiller`, `MATRIX_AutoPrompter`, `MATRIX_ImageBatchLoader`, `MATRIX_Resolution`, `MATRIX_AIInfluencerResolution`, `MATRIX_AIInfluencerResolution2K4K`, `MATRIX_EasyCrop`, and `MATRIX_SpectralSampler`.
Expand Down
2 changes: 1 addition & 1 deletion INSTALL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Installation for people

Install MATRIX LAB NODES package version 0.3.0 as one ComfyUI custom-node pack.
Install MATRIX LAB NODES package version 0.3.1 as one ComfyUI custom-node pack.

## Before you begin

Expand Down
24 changes: 12 additions & 12 deletions MANIFEST.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"version": "1.0.0"
},
{
"content_hash": "177c945bf563a1adeb831a8b87d268725fc9dd10bb423b51e1f16704d9fe3f2f",
"content_hash": "4d69e10922bb3df4d9247854acfcd46d9a11cf77aae273c9000fc0bf76d256b1",
"id": "ui.appearance",
"version": "0.5.1"
},
Expand Down Expand Up @@ -51,7 +51,7 @@
"version": "0.1.5"
},
{
"content_hash": "f6bb4725513b77c56106291847de8ff391378d49b7e67956927c0715e02365b4",
"content_hash": "206b56e60437edf655c3cceef285a1ee01fcf67071e14c16f79de13822a92dc0",
"id": "io.save-clean",
"version": "0.2.0"
},
Expand Down Expand Up @@ -132,7 +132,7 @@
"MATRIX_SpectralSampler": "MATRIX LAB/Sampling & Detail",
"MATRIX_AIInfluencerResolution2K4K": "MATRIX LAB/Resolution & Layout"
},
"compiler_sha256": "deb114d7e9674a9c2ec2a24b9d7c95e008eeee17fc74e7da808805db8fb76469",
"compiler_sha256": "2995d2bbbabd0194743b85975e855fa384d7681b5caa394c9d1c458a41bfafda",
"declaration_hashes": {
"MATRIX_AIInfluencerResolution": "9dd100da79f07f132edda489d044fc2389017ac74ec7401f33d63f0f2ece3ce7",
"MATRIX_AutoPrompter": "1f9b62b8c5b9d5eba5596aeb3493b1d62b7978b5a989a904f09f5bf1af9006b9",
Expand All @@ -151,13 +151,13 @@
},
"display_name": "MATRIX LAB NODES",
"frontend_entrypoints": [
"web/ai_influencer_resolution.d47e945783aa6298.js",
"web/ai_influencer_resolution_2k4k.203f0bdb857d180d.js",
"web/easy_crop.4fae2e79d5ac41d4.js",
"web/halo_execution.615447e8edacbd1a.js",
"web/image_collection.4a857a26ffb9735e.js",
"web/prompt_director.402dd6f04f072edf.js",
"web/resolution_control_deck.11b94ed50b65d983.js"
"web/ai_influencer_resolution.4494789dd5805c35.js",
"web/ai_influencer_resolution_2k4k.3a36028916c21b63.js",
"web/easy_crop.ecce21d7a6f52c16.js",
"web/halo_execution.035911ef803c28bc.js",
"web/image_collection.cd03d1d6a98d71d5.js",
"web/prompt_director.a37de4ea51073b4f.js",
"web/resolution_control_deck.6e1ff85eab697cf7.js"
],
"nodes": [
"MATRIX_AIInfluencerResolution",
Expand All @@ -178,11 +178,11 @@
"pack_id": "matrix-lab-nodes",
"runtime_asset_registry_sha256": "ab1955cfe5e8d05b832a4f63cae121d9afd6f74d3a9048b0107cf634659cada8",
"status": "development",
"version": "0.3.0",
"version": "0.3.1",
"candidate_provenance": {
"strategy": "current-canonical-base-plus-additive-factory-node",
"base_builder_sha256": "831ce90d7ec059f0538c8b690e2078339fddd81ecf8197fb8ec4767f920606b4",
"compiler_sha256": "deb114d7e9674a9c2ec2a24b9d7c95e008eeee17fc74e7da808805db8fb76469",
"compiler_sha256": "2995d2bbbabd0194743b85975e855fa384d7681b5caa394c9d1c458a41bfafda",
"builder_sha256": "f60506d9d5a1597c860f08d6b8a8119a265b4e50ef42e4930defc60e9bc55ab3",
"existing_node_implementation_policy": "fresh-canonical-base-build",
"runtime_registry_policy": "owned-by-canonical-base-builder",
Expand Down
2 changes: 1 addition & 1 deletion NODES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Node reference

Package version 0.3.0 contains the fourteen classes below in six `MATRIX LAB` groups. The installed `MANIFEST.json` is authoritative for a particular artifact.
Package version 0.3.1 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 |
| --- | --- | --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Fourteen focused ComfyUI nodes for image input, resolution, sampling, masks, finishing, output, and assisted prompting—organized in six consistent `MATRIX LAB` categories.

> **Development candidate 0.3.0.** Read the current [compatibility and acceptance boundary](docs/compatibility.md) before using it in an important workflow.
> **Development candidate 0.3.1.** Read the current [compatibility and acceptance boundary](docs/compatibility.md) before using it in an important workflow.

## Installation

Expand Down
2 changes: 2 additions & 0 deletions _core/io_save_clean/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Callable

from .save_clean import (
_default_preview_directory,
SaveClean,
inspect_jpeg_privacy,
inspect_png_privacy,
Expand Down Expand Up @@ -38,6 +39,7 @@ def execute_utility_operation(item):
item.get("quality", 100),
path_allocator=_FACTORY_PATH_ALLOCATOR,
interrupt_checker=_FACTORY_INTERRUPT_CHECKER,
preview_dir=_default_preview_directory,
)

__all__ = [
Expand Down
156 changes: 142 additions & 14 deletions _core/io_save_clean/save_clean.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
import struct
import tempfile
import unicodedata
import uuid
import zlib
from pathlib import Path
from typing import Callable
from typing import Callable, Any

import torch
from PIL import Image
Expand All @@ -19,6 +20,7 @@
InterruptChecker = Callable[[], None]
Encoder = Callable[[Image.Image, str | os.PathLike[str], int], None]
Verifier = Callable[[str | os.PathLike[str]], bool]
MAX_COLLISION_RETRIES = 1000


def _default_path_allocator(prefix: str, width: int, height: int):
Expand All @@ -35,6 +37,12 @@ def _default_interrupt_checker() -> None:
throw_exception_if_processing_interrupted()


def _default_preview_directory() -> str:
import folder_paths

return folder_paths.get_temp_directory()


def _encode_jpeg(image: Image.Image, path: str | os.PathLike[str], quality: int) -> None:
image.save(
path,
Expand All @@ -59,6 +67,13 @@ def _validate_prefix(filename_prefix: object) -> str:
raise ValueError("filename_prefix must be non-empty after trimming")
if len(prefix) > 128:
raise ValueError("filename_prefix must contain at most 128 characters")
if filename_prefix.endswith((".", " ")):
raise ValueError("filename_prefix may not end with a dot or space")
if any(ch in '<>:"/\\|?*' for ch in prefix):
raise ValueError("filename_prefix contains a character forbidden in portable filenames")
stem = prefix.split(".", 1)[0].rstrip(" .").upper()
if stem in {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} or re.fullmatch(r"(?:COM|LPT)[1-9¹²³]", stem):
raise ValueError("filename_prefix may not use a reserved Windows device name")
if prefix in {".", ".."}:
raise ValueError("filename_prefix may not be a path component")
if "/" in prefix or "\\" in prefix or re.match(r"^[A-Za-z]:", prefix):
Expand Down Expand Up @@ -96,8 +111,10 @@ def _validate_inputs(
raise ValueError("IMAGE batch must contain at least one frame")
if height < 1 or width < 1:
raise ValueError(f"IMAGE dimensions must be positive, received {list(images.shape)}")
if channels not in (1, 3):
if channels not in (1, 3, 4):
raise ValueError(f"unsupported IMAGE channel count, received {list(images.shape)}")
if channels == 4 and format == "JPEG":
raise ValueError("JPEG cannot preserve alpha channels; select PNG for RGBA images")
if images.dtype not in _supported_dtypes():
raise TypeError(f"unsupported IMAGE dtype: {images.dtype}")

Expand All @@ -122,6 +139,8 @@ def _to_pillow(frame: torch.Tensor) -> Image.Image:
pixels = _frame_to_uint8(frame).numpy()
if pixels.shape[2] == 1:
return Image.fromarray(pixels[:, :, 0], mode="L")
if pixels.shape[2] == 4:
return Image.fromarray(pixels, mode="RGBA")
return Image.fromarray(pixels, mode="RGB")


Expand Down Expand Up @@ -246,7 +265,88 @@ def verify_clean_png(path: str | os.PathLike[str]) -> bool:
def _publish_without_overwrite(temp_path: Path, final_path: Path) -> None:
# A same-directory hard link is atomic and fails if the destination exists.
os.link(temp_path, final_path)
temp_path.unlink()


def _verify_clean_preview(path: Path) -> bool:
"""Accept only static WebP pixel chunks, with an exact RIFF boundary."""
payload = path.read_bytes()
if len(payload) < 12 or payload[:4] != b"RIFF" or payload[8:12] != b"WEBP":
return False
if int.from_bytes(payload[4:8], "little") + 8 != len(payload):
return False
position = 12
while position < len(payload):
if position + 8 > len(payload):
return False
kind = payload[position:position + 4]
length = int.from_bytes(payload[position + 4:position + 8], "little")
if kind not in {b"VP8 ", b"VP8L", b"VP8X", b"ALPH"}:
return False
end = position + 8 + length
if end > len(payload):
return False
if kind == b"VP8X" and (length != 10 or payload[position + 8] & ~0x10):
return False
position = end + (length & 1)
if position != len(payload):
return False
with Image.open(path) as image:
image.load()
return (image.format == "WEBP" and not image.is_animated
and max(image.size) <= 1024 and min(image.size) > 0
and not any(key in image.info for key in ("exif", "xmp", "icc_profile", "comment"))
and not image.getexif())


def _save_preview(original: Path, preview_dir: str | os.PathLike[str]) -> dict[str, str]:
# Decode the verified saved original, including JPEG encoding changes. The
# preview therefore represents the delivered file rather than pre-encode pixels.
with Image.open(original) as saved:
saved.load()
pixels = saved.convert("RGBA" if "A" in saved.getbands() else "RGB")
pixels.info.clear()
pixels.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
directory = Path(preview_dir)
filename = f"matrix-preview-{uuid.uuid4().hex}.webp"
handle, name = tempfile.mkstemp(prefix=".matrix-preview-", suffix=".tmp", dir=directory)
os.close(handle)
temporary = Path(name)
try:
pixels.save(temporary, format="WEBP", quality=85, method=2)
if not _verify_clean_preview(temporary):
raise RuntimeError("Preview metadata verification failed")
_publish_without_overwrite(temporary, directory / filename)
finally:
temporary.unlink(missing_ok=True)
return {"filename": filename, "subfolder": "", "type": "temp"}


def _report_partial_failure(error: BaseException, descriptors: list[dict[str, Any]], total: int) -> None:
filenames = [item["filename"] for item in descriptors]
message = f"Metadata Killer saved {len(filenames)} of {total} files before stopping"
if filenames:
message += ": " + ", ".join(filenames)
# Python 3.10 has no BaseException.add_note. Diagnostic enrichment must
# never replace the real save/cancellation failure on any supported runtime.
add_note = getattr(error, "add_note", None)
if callable(add_note):
try:
add_note(message)
except Exception:
pass
try:
error.matrix_published_files = tuple(filenames)
except Exception:
pass
# Preserve the exception class (especially ComfyUI cancellation), while making
# the ordinary runtime error message truthful about already published files.
try:
if isinstance(error, OSError) and isinstance(error.strerror, str):
error.strerror = error.strerror + "; " + message
elif len(error.args) == 1 and isinstance(error.args[0], str):
error.args = (error.args[0] + "; " + message,)
except Exception:
pass


def save_clean_images(
Expand All @@ -260,7 +360,8 @@ def save_clean_images(
jpeg_verifier: Verifier | None = None,
png_verifier: Verifier | None = None,
encoder: Encoder | None = None,
) -> dict[str, dict[str, list[dict[str, str]]]]:
preview_dir: str | os.PathLike[str] | Callable[[], str] | None = None,
) -> dict[str, dict[str, list[dict[str, Any]]]]:
allocator = path_allocator or _default_path_allocator
check_interrupted = interrupt_checker or _default_interrupt_checker
images, prefix, output_format, quality = _validate_inputs(
Expand All @@ -279,14 +380,14 @@ def save_clean_images(
_, height, width, _ = images.shape
output_folder, basename, counter, subfolder, _ = allocator(prefix, width, height)
output_dir = Path(output_folder)
descriptors: list[dict[str, str]] = []
descriptors: list[dict[str, Any]] = []

for index, frame in enumerate(images):
check_interrupted()
filename = f"{basename}_{counter + index:05}_.{extension}"
for frame in images:
filename = f"{basename}_{counter:05}_.{extension}"
final_path = output_dir / filename
temp_path: Path | None = None
try:
check_interrupted()
handle, raw_temp_path = tempfile.mkstemp(
prefix=f".{filename}.", suffix=".tmp", dir=output_dir
)
Expand All @@ -298,17 +399,41 @@ def save_clean_images(
f"{output_format} metadata verification failed closed"
)
check_interrupted()
_publish_without_overwrite(temp_path, final_path)
temp_path = None
for attempt in range(MAX_COLLISION_RETRIES):
try:
_publish_without_overwrite(temp_path, final_path)
break
except FileExistsError:
check_interrupted()
counter += 1
filename = f"{basename}_{counter:05}_.{extension}"
final_path = output_dir / filename
else:
raise FileExistsError("Output name collision retry limit reached; existing files preserved")
except BaseException as error:
_report_partial_failure(error, descriptors, len(images))
raise
finally:
if temp_path is not None:
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
descriptors.append(
{"filename": filename, "subfolder": subfolder, "type": "output"}
)
descriptor = {"filename": filename, "subfolder": subfolder, "type": "output", "matrix_metadata_verified": True}
descriptors.append(descriptor)
counter += 1
if preview_dir is not None:
try:
try:
directory = preview_dir() if callable(preview_dir) else preview_dir
descriptor["matrix_preview"] = _save_preview(final_path, directory)
except Exception:
# Preview is optional, and must not downgrade a verified original.
descriptor["matrix_preview_error"] = "Preview unavailable; original saved and verified."
check_interrupted()
except BaseException as error:
_report_partial_failure(error, descriptors, len(images))
raise
return {"ui": {"images": descriptors}}


Expand All @@ -335,4 +460,7 @@ def INPUT_TYPES(cls):
}

def save(self, images, filename_prefix, format, quality):
return save_clean_images(images, filename_prefix, format, quality)
return save_clean_images(
images, filename_prefix, format, quality,
preview_dir=_default_preview_directory,
)
2 changes: 1 addition & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Current status

Version 0.3.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.
Version 0.3.1 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 |
| --- | --- |
Expand Down
Loading