From 5b7c0258bfba7937548a4faed9e672482e84632f Mon Sep 17 00:00:00 2001 From: MATRIX LAB <310160138+JsonMatrixLab@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:51:05 +0200 Subject: [PATCH] Improve Metadata Killer previews and export controls for 0.3.1 --- AGENT-INSTALL.md | 4 +- CHANGELOG.md | 10 + INSTALL.md | 2 +- MANIFEST.json | 24 +- NODES.md | 2 +- README.md | 2 +- _core/io_save_clean/__init__.py | 2 + _core/io_save_clean/save_clean.py | 156 ++++++++- docs/compatibility.md | 2 +- docs/nodes/metadata-killer.md | 37 +- nodes/image_processing/matrix_easycrop.py | 2 + nodes/image_processing/matrix_outputstage.py | 2 + .../image_processing/matrix_photofinisher.py | 2 + nodes/input_output/matrix_imagebatchloader.py | 2 + nodes/input_output/matrix_metadatakiller.py | 2 + nodes/masks_detection/matrix_eyemask.py | 2 + nodes/masks_detection/matrix_skinmask.py | 2 + .../matrix_aiinfluencerresolution.py | 2 + .../matrix_aiinfluencerresolution2k4k.py | 2 + nodes/resolution_layout/matrix_resolution.py | 2 + nodes/sampling_detail/matrix_croptailpaste.py | 2 + nodes/sampling_detail/matrix_latenttail.py | 2 + .../sampling_detail/matrix_spectralsampler.py | 2 + pyproject.toml | 2 +- ...influencer_resolution.4494789dd5805c35.js} | 2 +- ...encer_resolution_2k4k.3a36028916c21b63.js} | 2 +- ...c41d4.js => easy_crop.ecce21d7a6f52c16.js} | 2 +- ...a60e1.mjs => gallery.2fa7e326ff4dc0d0.mjs} | 2 +- ...ccfeb9ce.mjs => halo.dd87986d74909bc0.mjs} | 326 ++++++++++++++++-- ....js => halo_execution.035911ef803c28bc.js} | 2 +- ...s => halo_resolution.7078744b005764b9.mjs} | 2 +- ...s => image_collection.cd03d1d6a98d71d5.js} | 4 +- ...js => prompt_director.a37de4ea51073b4f.js} | 2 +- ...solution_control_deck.6e1ff85eab697cf7.js} | 2 +- 34 files changed, 531 insertions(+), 84 deletions(-) rename web/{ai_influencer_resolution.d47e945783aa6298.js => ai_influencer_resolution.4494789dd5805c35.js} (99%) rename web/{ai_influencer_resolution_2k4k.203f0bdb857d180d.js => ai_influencer_resolution_2k4k.3a36028916c21b63.js} (99%) rename web/{easy_crop.4fae2e79d5ac41d4.js => easy_crop.ecce21d7a6f52c16.js} (99%) rename web/{gallery.1b81fe83387a60e1.mjs => gallery.2fa7e326ff4dc0d0.mjs} (99%) rename web/{halo.d64a9a13ccfeb9ce.mjs => halo.dd87986d74909bc0.mjs} (79%) rename web/{halo_execution.615447e8edacbd1a.js => halo_execution.035911ef803c28bc.js} (88%) rename web/{halo_resolution.83fe73faf4ac1a41.mjs => halo_resolution.7078744b005764b9.mjs} (99%) rename web/{image_collection.4a857a26ffb9735e.js => image_collection.cd03d1d6a98d71d5.js} (98%) rename web/{prompt_director.402dd6f04f072edf.js => prompt_director.a37de4ea51073b4f.js} (99%) rename web/{resolution_control_deck.11b94ed50b65d983.js => resolution_control_deck.6e1ff85eab697cf7.js} (99%) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 9dee001..1d7f084 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -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 @@ -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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index f394aaf..f0bb851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/INSTALL.md b/INSTALL.md index 4a89fae..69786d9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 diff --git a/MANIFEST.json b/MANIFEST.json index b042b73..3c883dc 100644 --- a/MANIFEST.json +++ b/MANIFEST.json @@ -21,7 +21,7 @@ "version": "1.0.0" }, { - "content_hash": "177c945bf563a1adeb831a8b87d268725fc9dd10bb423b51e1f16704d9fe3f2f", + "content_hash": "4d69e10922bb3df4d9247854acfcd46d9a11cf77aae273c9000fc0bf76d256b1", "id": "ui.appearance", "version": "0.5.1" }, @@ -51,7 +51,7 @@ "version": "0.1.5" }, { - "content_hash": "f6bb4725513b77c56106291847de8ff391378d49b7e67956927c0715e02365b4", + "content_hash": "206b56e60437edf655c3cceef285a1ee01fcf67071e14c16f79de13822a92dc0", "id": "io.save-clean", "version": "0.2.0" }, @@ -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", @@ -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", @@ -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", diff --git a/NODES.md b/NODES.md index 7cde61e..9b9a231 100644 --- a/NODES.md +++ b/NODES.md @@ -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 | | --- | --- | --- | --- | --- | diff --git a/README.md b/README.md index 60e83c9..b5f42de 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/_core/io_save_clean/__init__.py b/_core/io_save_clean/__init__.py index 7d99193..c6445a8 100644 --- a/_core/io_save_clean/__init__.py +++ b/_core/io_save_clean/__init__.py @@ -3,6 +3,7 @@ from typing import Callable from .save_clean import ( + _default_preview_directory, SaveClean, inspect_jpeg_privacy, inspect_png_privacy, @@ -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__ = [ diff --git a/_core/io_save_clean/save_clean.py b/_core/io_save_clean/save_clean.py index 8543c71..36c4282 100644 --- a/_core/io_save_clean/save_clean.py +++ b/_core/io_save_clean/save_clean.py @@ -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 @@ -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): @@ -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, @@ -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): @@ -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}") @@ -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") @@ -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( @@ -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( @@ -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 ) @@ -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}} @@ -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, + ) diff --git a/docs/compatibility.md b/docs/compatibility.md index 32e5523..992cf18 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -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 | | --- | --- | diff --git a/docs/nodes/metadata-killer.md b/docs/nodes/metadata-killer.md index 46f6467..faabc13 100644 --- a/docs/nodes/metadata-killer.md +++ b/docs/nodes/metadata-killer.md @@ -2,12 +2,39 @@ Class ID: `MATRIX_MetadataKiller` - 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. +Save each image in a batch as a verified clean JPEG or PNG, with a small preview inside the node. Metadata Killer is an output node with one `IMAGE` input and no output sockets. + +## Save controls + +**Name** sets the filename prefix, initially `MATRIX`. Each saved image receives a numbered suffix. Use a plain filename prefix; directories, Windows reserved device names, forbidden filename characters and trailing dots or spaces are rejected. Concurrent saves select another available counter instead of overwriting an existing output. + +**Export** selects the format and JPEG quality: + +| Choice | Saved original | +| --- | --- | +| JPEG · High quality | JPEG at quality 95 | +| JPEG · Smaller file | JPEG at quality 85 | +| PNG · Lossless | PNG; JPEG quality does not affect it | +| JPEG · Custom | JPEG with an editable quality from 1 to 100 | + +The stored defaults remain JPEG and quality 100, represented as custom quality. Editing custom quality does not switch the control to a preset, even when the value reaches 85 or 95. PNG keeps the stored JPEG quality for later use. Linked format or quality inputs remain authoritative; conflicting convenience controls are disabled while linked. + +Existing workflows and API prompts keep the same save parameters: required `images`; optional `filename_prefix` (default `MATRIX`), `format` (`JPEG` or `PNG`, default `JPEG`), and `quality` (integer 1–100, default 100). Export is a convenience control over these values, not an additional saved input. + +## Preview and original files + +The contained preview preserves aspect ratio and uses a temporary WebP at up to 1,024 pixels on its longer edge. It represents the saved original and does not change the original file. Use batch navigation to inspect other saved images. The previous preview remains visible while the selected one loads. + +**Open original** opens the full-resolution file in a dialog. **Download original** downloads that original file, not the small preview. The dialog has its own close control. The filename and metadata-verification status refer to the saved original. + +If a preview cannot load, the original remains saved. **Retry preview** retries image loading without executing the workflow or saving another file. Temporary previews may expire; workflow reopening alone does not guarantee restoration of earlier execution images. + +## Image and privacy behavior + +JPEG uses fixed 4:4:4 chroma sampling. PNG preserves the quantized eight-bit pixels, including RGBA alpha. JPEG cannot preserve alpha and refuses RGBA input with a message directing you to PNG; it does not silently flatten transparency. Finite floating-point grayscale, RGB and supported PNG RGBA batches retain their dimensions. Empty or invalid images are rejected. -- Required: `images` (`IMAGE`) -- Options: `filename_prefix` (default `MATRIX`), `format` (`JPEG` or `PNG`, default `JPEG`), `quality` (1–100, default `100`) +Every original is encoded fresh and checked for container metadata before publication. Prompt, workflow, EXIF, XMP, ICC and application metadata are not copied into the output. The node does not remove information already burned into visible pixels. -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. +Files are published one at a time. If saving a later batch image fails or is interrupted, earlier verified files remain available and the error reports their count and filenames. Optional preview failure does not turn a successfully saved original into a failed save. diff --git a/nodes/image_processing/matrix_easycrop.py b/nodes/image_processing/matrix_easycrop.py index 652acdd..4ee18b6 100644 --- a/nodes/image_processing/matrix_easycrop.py +++ b/nodes/image_processing/matrix_easycrop.py @@ -54,6 +54,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/image_processing/matrix_outputstage.py b/nodes/image_processing/matrix_outputstage.py index b93cd92..47cd095 100644 --- a/nodes/image_processing/matrix_outputstage.py +++ b/nodes/image_processing/matrix_outputstage.py @@ -62,6 +62,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/image_processing/matrix_photofinisher.py b/nodes/image_processing/matrix_photofinisher.py index b991616..c494ef1 100644 --- a/nodes/image_processing/matrix_photofinisher.py +++ b/nodes/image_processing/matrix_photofinisher.py @@ -56,6 +56,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/input_output/matrix_imagebatchloader.py b/nodes/input_output/matrix_imagebatchloader.py index 9f7d089..6cf84e5 100644 --- a/nodes/input_output/matrix_imagebatchloader.py +++ b/nodes/input_output/matrix_imagebatchloader.py @@ -47,6 +47,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/input_output/matrix_metadatakiller.py b/nodes/input_output/matrix_metadatakiller.py index 20c4f4c..6b2b61f 100644 --- a/nodes/input_output/matrix_metadatakiller.py +++ b/nodes/input_output/matrix_metadatakiller.py @@ -50,6 +50,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/masks_detection/matrix_eyemask.py b/nodes/masks_detection/matrix_eyemask.py index 40b2c8e..ab45190 100644 --- a/nodes/masks_detection/matrix_eyemask.py +++ b/nodes/masks_detection/matrix_eyemask.py @@ -55,6 +55,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/masks_detection/matrix_skinmask.py b/nodes/masks_detection/matrix_skinmask.py index 65fd1c3..144708d 100644 --- a/nodes/masks_detection/matrix_skinmask.py +++ b/nodes/masks_detection/matrix_skinmask.py @@ -55,6 +55,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/resolution_layout/matrix_aiinfluencerresolution.py b/nodes/resolution_layout/matrix_aiinfluencerresolution.py index 8e7287d..b7c41c7 100644 --- a/nodes/resolution_layout/matrix_aiinfluencerresolution.py +++ b/nodes/resolution_layout/matrix_aiinfluencerresolution.py @@ -49,6 +49,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/resolution_layout/matrix_aiinfluencerresolution2k4k.py b/nodes/resolution_layout/matrix_aiinfluencerresolution2k4k.py index e96b257..1fba589 100644 --- a/nodes/resolution_layout/matrix_aiinfluencerresolution2k4k.py +++ b/nodes/resolution_layout/matrix_aiinfluencerresolution2k4k.py @@ -49,6 +49,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/resolution_layout/matrix_resolution.py b/nodes/resolution_layout/matrix_resolution.py index a79790a..5029b57 100644 --- a/nodes/resolution_layout/matrix_resolution.py +++ b/nodes/resolution_layout/matrix_resolution.py @@ -51,6 +51,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/sampling_detail/matrix_croptailpaste.py b/nodes/sampling_detail/matrix_croptailpaste.py index 4d3a93f..64db634 100644 --- a/nodes/sampling_detail/matrix_croptailpaste.py +++ b/nodes/sampling_detail/matrix_croptailpaste.py @@ -61,6 +61,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/sampling_detail/matrix_latenttail.py b/nodes/sampling_detail/matrix_latenttail.py index 39e8e36..63dd8e9 100644 --- a/nodes/sampling_detail/matrix_latenttail.py +++ b/nodes/sampling_detail/matrix_latenttail.py @@ -55,6 +55,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/nodes/sampling_detail/matrix_spectralsampler.py b/nodes/sampling_detail/matrix_spectralsampler.py index 418167b..c113832 100644 --- a/nodes/sampling_detail/matrix_spectralsampler.py +++ b/nodes/sampling_detail/matrix_spectralsampler.py @@ -56,6 +56,8 @@ def _adapt_image_inputs(inputs): # Upload-backed IMAGE widgets carry a filename until their operation adapter. if not isinstance(value, torch.Tensor): continue + if value.numel() == 0: + raise ValueError('IMAGE contract violation: empty tensor; batch and image dimensions must be positive') if not bool(torch.isfinite(value).all().item()): raise ValueError('IMAGE contract violation: all values must be finite') minimum = value.amin().item() diff --git a/pyproject.toml b/pyproject.toml index e7a1b91..201642e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "matrix-lab-nodes" -version = "0.3.0" +version = "0.3.1" description = "MATRIX LAB custom nodes for ComfyUI." readme = "README.md" requires-python = ">=3.10" diff --git a/web/ai_influencer_resolution.d47e945783aa6298.js b/web/ai_influencer_resolution.4494789dd5805c35.js similarity index 99% rename from web/ai_influencer_resolution.d47e945783aa6298.js rename to web/ai_influencer_resolution.4494789dd5805c35.js index dd927f2..edcf453 100644 --- a/web/ai_influencer_resolution.d47e945783aa6298.js +++ b/web/ai_influencer_resolution.4494789dd5805c35.js @@ -1,5 +1,5 @@ import { app } from "../../scripts/app.js"; -import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, setHaloNodeSize, calculateTierResolution, migrateResolutionGraph } from "./halo_resolution.83fe73faf4ac1a41.mjs"; +import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, setHaloNodeSize, calculateTierResolution, migrateResolutionGraph } from "./halo_resolution.7078744b005764b9.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.203f0bdb857d180d.js b/web/ai_influencer_resolution_2k4k.3a36028916c21b63.js similarity index 99% rename from web/ai_influencer_resolution_2k4k.203f0bdb857d180d.js rename to web/ai_influencer_resolution_2k4k.3a36028916c21b63.js index f7cc734..b04b672 100644 --- a/web/ai_influencer_resolution_2k4k.203f0bdb857d180d.js +++ b/web/ai_influencer_resolution_2k4k.3a36028916c21b63.js @@ -1,5 +1,5 @@ import { app } from "../../scripts/app.js"; -import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, setHaloNodeSize, calculateTierResolution } from "./halo_resolution.83fe73faf4ac1a41.mjs"; +import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, setHaloNodeSize, calculateTierResolution } from "./halo_resolution.7078744b005764b9.mjs"; const ASPECT_RATIOS = ["1:1", "9:16", "3:4"]; const RESOLUTION_TIERS = ["2K", "4K"]; export function calculateResolution(state) { diff --git a/web/easy_crop.4fae2e79d5ac41d4.js b/web/easy_crop.ecce21d7a6f52c16.js similarity index 99% rename from web/easy_crop.4fae2e79d5ac41d4.js rename to web/easy_crop.ecce21d7a6f52c16.js index f1905fe..64097eb 100644 --- a/web/easy_crop.4fae2e79d5ac41d4.js +++ b/web/easy_crop.ecce21d7a6f52c16.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.d64a9a13ccfeb9ce.mjs"; +import { createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, mountHaloSurface, setHaloNodeSize } from "./halo.dd87986d74909bc0.mjs"; const NODE_IDS = new Set(["MATRIX_EasyCrop"]); export const CONTROL = Symbol.for("matrixlab.easy-crop.control"); diff --git a/web/gallery.1b81fe83387a60e1.mjs b/web/gallery.2fa7e326ff4dc0d0.mjs similarity index 99% rename from web/gallery.1b81fe83387a60e1.mjs rename to web/gallery.2fa7e326ff4dc0d0.mjs index cbffce1..643ff06 100644 --- a/web/gallery.1b81fe83387a60e1.mjs +++ b/web/gallery.2fa7e326ff4dc0d0.mjs @@ -1,4 +1,4 @@ -import { haloMinimumNodeHeight, measureHaloContentHeight, mountHaloSurface, setHaloNodeSize } from "./halo.d64a9a13ccfeb9ce.mjs"; +import { haloMinimumNodeHeight, measureHaloContentHeight, mountHaloSurface, setHaloNodeSize } from "./halo.dd87986d74909bc0.mjs"; export const GALLERY_STATE_VERSION = 1; export const MAX_GALLERY_IMAGES = 10; diff --git a/web/halo.d64a9a13ccfeb9ce.mjs b/web/halo.dd87986d74909bc0.mjs similarity index 79% rename from web/halo.d64a9a13ccfeb9ce.mjs rename to web/halo.dd87986d74909bc0.mjs index 1ec62c8..c5f165f 100644 --- a/web/halo.d64a9a13ccfeb9ce.mjs +++ b/web/halo.dd87986d74909bc0.mjs @@ -446,8 +446,8 @@ export function registerHaloMotionSetting(app, options = {}) { host[SETTING_KEY] = state; const definition = { id: HALO_MOTION_SETTING_ID, - name: "MATRIXLAB HALO motion", - tooltip: "Animate MATRIXLAB HALO rain and perimeter effects.", + name: "MATRIX LAB HALO motion", + tooltip: "Animate MATRIX LAB HALO rain and perimeter effects.", type: "boolean", defaultValue: true, onChange(value) { @@ -1094,6 +1094,9 @@ function coerceValue(widget, raw) { // A presentation policy only: keep backend names, values, order and links intact. function parameterFieldPresentation(node, widget) { + if ((node.comfyClass || node.type) === "MATRIX_MetadataKiller") { + return { label: ({ filename_prefix: "Name", format: "Format", quality: "Quality" })[widget.name] }; + } if ((node.comfyClass || node.type) === "MATRIX_CropTailPaste" && widget.name === "mask_mode") { return { label: "Mask behavior", hidden: false, choices: [ @@ -1265,7 +1268,75 @@ function createWidgetField(doc, node, widget, options) { marker.textContent = "LINKED"; field.append(label, control, marker); render(); - return { field, render, destroy: () => control.removeEventListener("change", commit) }; + return { field, control, render, destroy: () => control.removeEventListener("change", commit) }; +} + +// Export is a projection of the existing format/quality pair, never a saved widget. +function createMetadataFields(doc, node, widgets, options) { + const format = widgets.find(widget => widget.name === "format"); + const quality = widgets.find(widget => widget.name === "quality"); + if (!format || !quality) return widgets.map(widget => createWidgetField(doc, node, widget, options)); + const native = widgets.map(widget => createWidgetField(doc, node, widget, options)); + const nativeRenderers = native.map(entry => entry.render); + const byName = new Map(widgets.map((widget, index) => [widget.name, native[index]])); + const field = doc.createElement("div"); field.className = "matrixlab-halo__field"; + field.style.flexWrap = "nowrap"; + const label = doc.createElement("label"); label.textContent = "Export"; + label.style.flex = "0 0 auto"; + const select = doc.createElement("select"); applyHaloSelect(select); + select.id = allocateControlId(doc); label.htmlFor = select.id; + Object.assign(select.style, { width: "auto", flex: "1 1 180px", minWidth: "0" }); + for (const [value, title] of [["high", "JPEG · High quality"], ["small", "JPEG · Smaller file"], ["png", "PNG · Lossless"], ["custom", "JPEG · Custom"]]) { + const item = doc.createElement("option"); item.value = value; item.textContent = title; select.appendChild(item); + } + field.append(label, select); + // Editor-open intent is disposable. Restored tuples always derive their preset. + const isCustomTuple = () => format.value === "JPEG" && ![95, 85].includes(quality.value); + let customEditorOpen = isCustomTuple(); + const linked = () => linkedInput(node, "format") || linkedInput(node, "quality"); + const preset = () => format.value === "PNG" ? "png" + : customEditorOpen ? "custom" + : quality.value === 95 ? "high" : quality.value === 85 ? "small" : "custom"; + const visible = (entry, show) => { entry.field.hidden = !show; entry.field.style.display = show ? "" : "none"; }; + const render = () => { + nativeRenderers.forEach(renderNative => renderNative()); + select.value = preset(); select.disabled = linked(); field.dataset.linked = String(linked()); + visible(byName.get("format"), linked()); + visible(byName.get("quality"), linked() || preset() === "custom"); + const name = byName.get("filename_prefix"); + if (name) { + name.field.style.flexWrap = "nowrap"; name.field.children[0].style.flex = "0 0 auto"; + Object.assign(name.control.style, { width: "auto", flex: "1 1 180px", textAlign: "left" }); + } + }; + const commit = event => { + if (linked() || !["high", "small", "png", "custom"].includes(select.value)) { render(); return; } + const choice = select.value; + const nextFormat = choice === "png" ? "PNG" : "JPEG"; + const nextQuality = choice === "high" ? 95 : choice === "small" ? 85 : quality.value; + const changed = [[format, nextFormat], [quality, nextQuality]].filter(([widget, value]) => !Object.is(widget.value, value)); + customEditorOpen = choice === "custom"; + if (!changed.length) { render(); options.onMetadataLayoutChange?.(); return; } + const canvas = options.app?.canvas || options.canvas || null; + node.graph?.beforeChange?.(); canvas?.emitBeforeChange?.(); + try { + // Both values are authoritative before either native callback observes them. + changed.forEach(([widget, value]) => { widget.value = value; }); + changed.forEach(([widget, value]) => widget.callback?.call(widget, value, canvas, node, + options.getCallbackPosition?.(event, node) ?? node.pos, event)); + } finally { + try { render(); options.onMetadataLayoutChange?.(); } + finally { try { node.graph?.afterChange?.(); } finally { canvas?.emitAfterChange?.(); } } + } + }; + select.addEventListener("change", commit); + const exportField = { field, render, reset() { customEditorOpen = isCustomTuple(); render(); }, + destroy() { select.removeEventListener("change", commit); } }; + native.forEach(entry => { entry.render = render; }); + render(); + const nameIndex = widgets.findIndex(widget => widget.name === "filename_prefix"); + const fields = [...native]; fields.splice(nameIndex < 0 ? 0 : nameIndex + 1, 0, exportField); + return fields; } // Keep output history in ComfyUI; own only the presentation of this node's saved images. @@ -1278,19 +1349,35 @@ export function mountHaloSavedImages(node, root, options = {}) { Object.assign(viewport.style, { position: "relative", flex: "1 1 auto", minHeight: "130px", overflow: "hidden", borderRadius: "9px", background: "rgba(0,0,0,0.25)" }); const status = doc.createElement("span"); status.textContent = "Saved image appears here"; + status.setAttribute("aria-live", "polite"); + const savedStatus = doc.createElement("span"); + savedStatus.className = "matrixlab-halo__saved-status"; + Object.assign(savedStatus.style, { minWidth: "0", overflowWrap: "anywhere" }); + const stale = doc.createElement("span"); stale.textContent = "Previous preview · loading selected image"; + Object.assign(stale.style, { position: "absolute", inset: "auto 0 0", zIndex: "1", padding: "8px", background: HALO_TOKENS.linkedSurface, color: HALO_TOKENS.primaryText }); + stale.hidden = true; const toolbar = doc.createElement("div"); Object.assign(toolbar.style, { display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "8px" }); const previous = doc.createElement("button"); previous.type = "button"; previous.textContent = "Previous"; const next = doc.createElement("button"); next.type = "button"; next.textContent = "Next"; - const open = doc.createElement("a"); open.textContent = "Open image"; open.target = "_blank"; open.rel = "noopener noreferrer"; - const download = doc.createElement("a"); download.textContent = "Download"; - toolbar.append(previous, status, next, open, download); - section.append(viewport, toolbar); root.appendChild(section); - let descriptors = [], selected = 0, pendingImage = null, revision = 0, destroyed = false; - let lastImages = null; + const open = doc.createElement("a"); open.textContent = "Open original"; + open.setAttribute("aria-haspopup", "dialog"); + const download = doc.createElement("a"); download.textContent = "Download original"; + const retry = doc.createElement("button"); retry.type = "button"; retry.textContent = "Retry preview"; retry.hidden = true; + toolbar.append(previous, status, next, open, download, retry); + section.append(viewport, toolbar, savedStatus); root.appendChild(section); + let descriptors = [], selected = 0, pendingImage = null, displayedImage = null, revision = 0, destroyed = false; + let lastSignature = null; + let originalViewer = null; + // At most three decoded thumbnails per node, independent of batch length. + const cache = new Map(); const previousHidden = node.hideOutputImages; + const nativeIdentity = new Map(["images", "preview"].map(name => [name, + { own: Object.prototype.hasOwnProperty.call(node, name), value: node[name], assigned: undefined }])); let ownsHidden = false; const nativeWidgets = new Map(); + const outputKey = () => node.graph?.isRootGraph === false ? `${node.graph.id}:${node.id}` : String(node.id); + const storedOutput = () => options.app?.nodeOutputs?.[outputKey()]; const syncNativeWidgets = () => { for (const widget of node.widgets || []) { if (widget.name !== "$$canvas-image-preview") continue; @@ -1307,63 +1394,221 @@ export function mountHaloSavedImages(node, root, options = {}) { }; const originalBackground = node.onDrawBackground; const background = function (...args) { + prepareNative(storedOutput()); + update(storedOutput()); try { return originalBackground?.apply(this, args); } - finally { update(options.app?.nodeOutputs?.[node.id]); syncNativeWidgets(); } + finally { syncNativeWidgets(); } }; node.onDrawBackground = background; const hideNative = (hidden) => { + const changed = hidden ? node.hideOutputImages !== true : ownsHidden; if (hidden) { node.hideOutputImages = true; ownsHidden = true; } else { if (ownsHidden && node.hideOutputImages === true) node.hideOutputImages = previousHidden; ownsHidden = false; } syncNativeWidgets(); - node.setDirtyCanvas?.(true, true); + if (changed) node.setDirtyCanvas?.(true, true); }; const cancelLoad = () => { - if (pendingImage) { pendingImage.onload = null; pendingImage.onerror = null; pendingImage = null; } + if (pendingImage) { + const image = pendingImage; pendingImage = null; + image.onload = null; image.onerror = null; + // Removing the actual browser source cancels the in-flight image fetch. + image.removeAttribute?.("src"); + } + }; + const prepareNative = output => { + if (destroyed) return; + hideNative(true); + // Classic updatePreviews compares these exact references before requesting + // URLs. Acknowledge its store without removing output/history descriptors. + const stored = storedOutput() || output; + if (stored && Object.prototype.hasOwnProperty.call(stored, "images")) { + node.images = stored.images; nativeIdentity.get("images").assigned = stored.images; + } + const previews = options.app?.nodePreviewImages?.[outputKey()]; + if (previews) { + node.preview = previews; nativeIdentity.get("preview").assigned = previews; + } + }; + const urlFor = (descriptor, thumbnail = false) => { + const query = new URLSearchParams({ filename: descriptor.filename, subfolder: descriptor.subfolder || "", type: descriptor.type || "output" }); + if (thumbnail) query.set("preview", "webp;90"); + return options.imageURL?.(query) ?? `${options.app?.api?.apiURL?.("/view") || "./view"}?${query}`; + }; + const closeOriginal = () => { + const viewer = originalViewer; + if (!viewer) return; + originalViewer = null; + viewer.image.onload = null; viewer.image.onerror = null; + viewer.image.removeAttribute?.("src"); + viewer.close.removeEventListener("click", viewer.dismiss); + viewer.dialog.removeEventListener("cancel", viewer.dismiss); + viewer.dialog.removeEventListener("close", viewer.dismiss); + viewer.dialog.removeEventListener("keydown", viewer.keydown); + doc.removeEventListener?.("keydown", viewer.keydown, true); + doc.removeEventListener?.("keyup", viewer.isolateKeyboard, true); + if (viewer.dialog.open) viewer.dialog.close?.(); + viewer.dialog.remove(); + if (!destroyed && open.isConnected) open.focus?.(); + }; + const openOriginal = event => { + if (destroyed || open.hidden || !descriptors[selected]) { event?.preventDefault?.(); return; } + // Attach to the document, outside ComfyUI's scaled canvas DOM. The original + // has no source until this explicit action successfully opens a native modal. + const descriptor = descriptors[selected]; + const dialog = doc.createElement("dialog"); + const parent = doc.body || doc.documentElement; + if (typeof dialog.showModal !== "function" || !parent?.appendChild) { + return; // Preserve the original href as a normal link on older frontends. + } + event?.preventDefault?.(); event?.stopPropagation?.(); + closeOriginal(); + dialog.className = "matrixlab-halo matrixlab-halo__original-dialog"; + Object.assign(dialog.style, { position: "fixed", margin: "auto", boxSizing: "border-box", padding: "16px", + width: "min(1100px, 94vw)", maxWidth: "94vw", maxHeight: "94vh", overflow: "auto", + border: `1px solid ${HALO_TOKENS.fieldBorder}`, borderRadius: "12px", + background: HALO_TOKENS.body, color: HALO_TOKENS.primaryText }); + const heading = doc.createElement("div"); + Object.assign(heading.style, { display: "flex", gap: "12px", alignItems: "center", justifyContent: "space-between" }); + const title = doc.createElement("h2"); title.id = allocateControlId(doc); + title.textContent = `Original · ${descriptor.filename}`; + Object.assign(title.style, { margin: "0", fontSize: "14px", overflowWrap: "anywhere", minWidth: "0" }); + dialog.setAttribute("aria-labelledby", title.id); + const close = doc.createElement("button"); close.type = "button"; close.textContent = "Close"; close.autofocus = true; + Object.assign(close.style, { flex: "0 0 auto", padding: "6px 12px", border: `1px solid ${HALO_TOKENS.fieldBorder}`, + borderRadius: "6px", background: HALO_TOKENS.parameterField, color: HALO_TOKENS.primaryText }); + const feedback = doc.createElement("p"); feedback.setAttribute("role", "status"); feedback.textContent = "Loading original…"; + const image = doc.createElement("img"); image.alt = descriptor.filename; + Object.assign(image.style, { display: "block", width: "100%", height: "min(72vh, 900px)", objectFit: "contain" }); + heading.append(title, close); dialog.append(heading, feedback, image); + const dismiss = event => { event?.preventDefault?.(); event?.stopPropagation?.(); closeOriginal(); }; + const isolateKeyboard = event => { + if (destroyed || originalViewer !== viewer || !dialog.open) return false; + event.stopImmediatePropagation?.(); event.stopPropagation?.(); + return true; + }; + const keydown = event => { + if (!isolateKeyboard(event)) return; + if (event.key === "Escape") dismiss(event); + else if (event.key === "Tab") { + // The viewer has one interactive control. Native traversal can escape + // to BODY in the embedded host, so cycle explicitly in both directions. + event.preventDefault?.(); close.focus?.(); + } + }; + const viewer = { dialog, close, image, dismiss, keydown, isolateKeyboard }; + originalViewer = viewer; + close.addEventListener("click", dismiss); dialog.addEventListener("cancel", dismiss); + dialog.addEventListener("close", dismiss); dialog.addEventListener("keydown", keydown); + // Capture outside the canvas also handles Escape after host focus escapes. + // Keep native button activation/scroll defaults; isolate graph shortcuts. + doc.addEventListener?.("keydown", keydown, true); + doc.addEventListener?.("keyup", isolateKeyboard, true); + image.onload = () => { + if (destroyed || originalViewer !== viewer) return; + feedback.textContent = "Original loaded"; + }; + image.onerror = () => { + if (destroyed || originalViewer !== viewer) return; + feedback.textContent = "Original could not be loaded. Close and try again, or use Download original."; + image.hidden = true; image.style.display = "none"; + }; + try { + parent.appendChild(dialog); dialog.showModal(); close.focus?.(); + image.src = urlFor(descriptor); + } catch { + closeOriginal(); status.textContent = "Original viewer unavailable. Use Download original."; + } + }; + const keyFor = descriptor => JSON.stringify([descriptor.filename, descriptor.subfolder || "", descriptor.type || "output", + descriptor.matrix_preview?.filename || "", descriptor.matrix_preview?.subfolder || "", descriptor.matrix_preview?.type || ""]); + const setStale = message => { + stale.hidden = !displayedImage; stale.textContent = message; + if (displayedImage) viewport.replaceChildren(displayedImage, stale); }; const show = () => { cancelLoad(); const token = ++revision; previous.disabled = selected <= 0; next.disabled = selected >= descriptors.length - 1; + previous.hidden = next.hidden = descriptors.length <= 1; + retry.hidden = true; open.hidden = download.hidden = true; - viewport.replaceChildren(); - if (!descriptors.length) { status.textContent = "Saved image appears here"; hideNative(false); return; } + if (!descriptors.length) { + viewport.replaceChildren(); displayedImage = null; stale.hidden = true; + status.textContent = "Saved image appears here"; savedStatus.textContent = ""; return; + } const descriptor = descriptors[selected]; - const query = new URLSearchParams({ filename: descriptor.filename, subfolder: descriptor.subfolder || "", type: descriptor.type || "output" }); - const url = options.imageURL?.(query) ?? `${options.app?.api?.apiURL?.("/view") || "./view"}?${query}`; + const originalURL = urlFor(descriptor); + const preview = descriptor.matrix_preview; + const url = preview?.filename && preview.type === "temp" ? urlFor(preview) : urlFor(descriptor, true); hideNative(true); - open.href = download.href = url; download.download = descriptor.filename; + open.href = download.href = originalURL; download.download = descriptor.filename; open.hidden = download.hidden = false; + open.title = download.title = `Selected saved original: ${descriptor.filename}`; + savedStatus.textContent = `Saved: ${descriptor.filename}${descriptor.matrix_metadata_verified === true ? " · Metadata verified" : ""}`; + const key = keyFor(descriptor); + const showReady = image => { + displayedImage = image; stale.hidden = true; viewport.replaceChildren(image); + status.textContent = `Preview ${selected + 1} / ${descriptors.length}`; + }; + if (cache.has(key)) { + const cached = cache.get(key); cache.delete(key); cache.set(key, cached); showReady(cached); return; + } const image = doc.createElement("img"); pendingImage = image; image.alt = descriptor.filename; Object.assign(image.style, { position: "absolute", inset: "0", width: "100%", height: "100%", objectFit: "contain" }); - status.textContent = `Loading ${selected + 1} / ${descriptors.length}`; + status.textContent = `Loading preview ${selected + 1} / ${descriptors.length}`; + setStale("Previous preview · loading selected image"); image.onload = () => { if (destroyed || revision !== token) return; - cancelLoad(); viewport.replaceChildren(image); - status.textContent = `${selected + 1} / ${descriptors.length}`; - + pendingImage = null; image.onload = null; image.onerror = null; + cache.set(key, image); + while (cache.size > 3) cache.delete(cache.keys().next().value); + showReady(image); }; image.onerror = () => { if (destroyed || revision !== token) return; - cancelLoad(); status.textContent = "Preview unavailable"; + cancelLoad(); status.textContent = "Preview unavailable"; retry.hidden = false; + setStale("Previous preview · selected preview unavailable"); }; image.src = url; }; const update = (output) => { if (destroyed || !output || !Object.prototype.hasOwnProperty.call(output, "images")) return; - if (output.images === lastImages) return; - lastImages = output.images; - descriptors = Array.isArray(output.images) ? output.images.filter(item => item && typeof item.filename === "string" && item.filename) : []; - selected = 0; show(); + prepareNative(output); + const incoming = Array.isArray(output.images) ? output.images.filter(item => item && typeof item.filename === "string" && item.filename) : []; + const signature = JSON.stringify(incoming.map(item => [keyFor(item), item.matrix_metadata_verified === true])); + if (signature === lastSignature) return; + const selectedKey = descriptors[selected] && keyFor(descriptors[selected]); + lastSignature = signature; descriptors = incoming; + const retained = descriptors.findIndex(item => keyFor(item) === selectedKey); + selected = retained < 0 ? 0 : retained; + const keep = new Set(descriptors.map(keyFor)); + for (const key of cache.keys()) if (!keep.has(key)) cache.delete(key); + show(); }; const goPrevious = () => { if (selected > 0) { selected--; show(); } }; const goNext = () => { if (selected + 1 < descriptors.length) { selected++; show(); } }; + const retryPreview = () => { if (!destroyed && descriptors.length && !retry.hidden) show(); }; previous.addEventListener("click", goPrevious); next.addEventListener("click", goNext); - show(); - return { section, update, destroy() { - destroyed = true; revision++; cancelLoad(); hideNative(false); + open.addEventListener("click", openOriginal); + retry.addEventListener("click", retryPreview); + prepareNative(storedOutput()); show(); + return { section, update, prepareNative, storedOutput, + minimumHeight: () => 130 + (Number(toolbar.offsetHeight) || 64) + (Number(savedStatus.offsetHeight) || 32) + 16, + destroy() { + destroyed = true; revision++; closeOriginal(); cancelLoad(); hideNative(false); + for (const image of cache.values()) image.removeAttribute?.("src"); + displayedImage?.removeAttribute?.("src"); viewport.replaceChildren(); + cache.clear(); displayedImage = null; + for (const [name, state] of nativeIdentity) { + if (state.assigned !== undefined && node[name] === state.assigned) { + if (state.own) node[name] = state.value; else delete node[name]; + } + } if (node.onDrawBackground === background) node.onDrawBackground = originalBackground; - previous.removeEventListener("click", goPrevious); next.removeEventListener("click", goNext); section.remove(); + previous.removeEventListener("click", goPrevious); next.removeEventListener("click", goNext); + open.removeEventListener("click", openOriginal); + retry.removeEventListener("click", retryPreview); section.remove(); } }; } @@ -1385,12 +1630,17 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, // 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)); + const namedWidgets = canonicalWidgets.filter(widget => widget?.name); + const fieldOptions = { ...options, onMetadataLayoutChange: () => scheduleMinimum() }; + const fields = nodeType === "MATRIX_MetadataKiller" + ? createMetadataFields(doc, node, namedWidgets, fieldOptions) + : namedWidgets.map(widget => createWidgetField(doc, node, widget, options)); for (const field of fields) root.appendChild(field.field); const savedImages = nodeType === "MATRIX_MetadataKiller" ? mountHaloSavedImages(node, root, options) : null; if (savedImages) Object.assign(root.style, { flex: "1 1 auto", minHeight: "0" }); const contentMinimum = () => savedImages - ? Math.max(fields.length * 46 + 36, ...fields.map(({ field }) => (Number(field.offsetTop) || 0) + (Number(field.offsetHeight) || 0) + 18)) + 188 + ? Math.max(fields.filter(({ field }) => !field.hidden).length * 46 + 36, + ...fields.filter(({ field }) => !field.hidden).map(({ field }) => (Number(field.offsetTop) || 0) + (Number(field.offsetHeight) || 0) + 18)) + savedImages.minimumHeight() : measureHaloContentHeight(root, fields.filter(({ field }) => !field.hidden).length * 46 + 36); let presentation = null; let halo = null; @@ -1492,7 +1742,7 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, const scheduleMinimum = () => { if (destroyed) return; clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { resizeTimer = null; savedImages?.update(options.app?.nodeOutputs?.[node.id]); minimumSize(); fields.forEach((field) => field.render()); halo.renderStatic(); }, 0); + resizeTimer = setTimeout(() => { resizeTimer = null; savedImages?.update(savedImages.storedOutput()); fields.forEach((field) => field.render()); minimumSize(); halo.renderStatic(); }, 0); }; // Canonical callbacks own state; the DOM is refreshed only after they finish. const refresh = () => { @@ -1511,7 +1761,10 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, const previous = node[name]; const wrapped = function (...args) { try { return previous?.apply(this, args); } - finally { savedImages?.update(options.app?.nodeOutputs?.[node.id]); refresh(); } + finally { + if (name === "onConfigure") fields.forEach(field => field.reset?.()); + savedImages?.update(savedImages.storedOutput()); refresh(); + } }; refreshHooks.push({ name, previous, wrapped }); node[name] = wrapped; @@ -1519,12 +1772,13 @@ function mountHaloPrimitiveNode(node, options = {}, allowedIds = EXECUTION_IDS, if (savedImages) { const previous = node.onExecuted; const wrapped = function (output, ...args) { + savedImages.prepareNative(output); try { return previous?.call(this, output, ...args); } - finally { savedImages.update(output); refresh(); } + finally { savedImages.update(savedImages.storedOutput() || output); refresh(); } }; refreshHooks.push({ name: "onExecuted", previous, wrapped }); node.onExecuted = wrapped; - savedImages.update(options.app?.nodeOutputs?.[node.id]); + savedImages.update(savedImages.storedOutput()); } wrappedResize = function (...args) { const result = previousResize?.apply(this, args); diff --git a/web/halo_execution.615447e8edacbd1a.js b/web/halo_execution.035911ef803c28bc.js similarity index 88% rename from web/halo_execution.615447e8edacbd1a.js rename to web/halo_execution.035911ef803c28bc.js index 9abca62..b22bd18 100644 --- a/web/halo_execution.615447e8edacbd1a.js +++ b/web/halo_execution.035911ef803c28bc.js @@ -1,5 +1,5 @@ import { app } from "../../scripts/app.js"; -import { mountHaloExecutionNode } from "./halo.d64a9a13ccfeb9ce.mjs"; +import { mountHaloExecutionNode } from "./halo.dd87986d74909bc0.mjs"; const ids = new Set(["MATRIX_CropTailPaste", "MATRIX_EyeMask", "MATRIX_LatentTail", "MATRIX_MetadataKiller", "MATRIX_OutputStage", "MATRIX_PhotoFinisher", "MATRIX_SkinMask", "MATRIX_SpectralSampler"]); const attach = (node) => { if (ids.has(node.comfyClass || node.type)) mountHaloExecutionNode(node, {app}); diff --git a/web/halo_resolution.83fe73faf4ac1a41.mjs b/web/halo_resolution.7078744b005764b9.mjs similarity index 99% rename from web/halo_resolution.83fe73faf4ac1a41.mjs rename to web/halo_resolution.7078744b005764b9.mjs index 6aee4f5..697efac 100644 --- a/web/halo_resolution.83fe73faf4ac1a41.mjs +++ b/web/halo_resolution.7078744b005764b9.mjs @@ -1,4 +1,4 @@ -import { HALO_TOKENS, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, measureHaloVerticalChrome, mountHaloSurface, setHaloNodeSize } from "./halo.d64a9a13ccfeb9ce.mjs"; +import { HALO_TOKENS, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, measureHaloVerticalChrome, mountHaloSurface, setHaloNodeSize } from "./halo.dd87986d74909bc0.mjs"; export { createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, measureHaloHorizontalChrome, measureHaloVerticalChrome, setHaloNodeSize }; diff --git a/web/image_collection.4a857a26ffb9735e.js b/web/image_collection.cd03d1d6a98d71d5.js similarity index 98% rename from web/image_collection.4a857a26ffb9735e.js rename to web/image_collection.cd03d1d6a98d71d5.js index b065ad3..6a4f677 100644 --- a/web/image_collection.4a857a26ffb9735e.js +++ b/web/image_collection.cd03d1d6a98d71d5.js @@ -1,5 +1,5 @@ -import { mountImageGallery } from "./gallery.1b81fe83387a60e1.mjs"; -import { createHaloWidgetHost, haloWidgetLayoutHeight, measureHaloContentHeight, setHaloNodeSize } from "./halo.d64a9a13ccfeb9ce.mjs"; +import { mountImageGallery } from "./gallery.2fa7e326ff4dc0d0.mjs"; +import { createHaloWidgetHost, haloWidgetLayoutHeight, measureHaloContentHeight, setHaloNodeSize } from "./halo.dd87986d74909bc0.mjs"; const { app } = globalThis.comfyAPI?.app || {}; const { api } = globalThis.comfyAPI?.api || {}; diff --git a/web/prompt_director.402dd6f04f072edf.js b/web/prompt_director.a37de4ea51073b4f.js similarity index 99% rename from web/prompt_director.402dd6f04f072edf.js rename to web/prompt_director.a37de4ea51073b4f.js index b544e18..33a1619 100644 --- a/web/prompt_director.402dd6f04f072edf.js +++ b/web/prompt_director.a37de4ea51073b4f.js @@ -6,7 +6,7 @@ import { measureHaloContentHeight, mountHaloSurface, setHaloNodeSize, -} from "./halo.d64a9a13ccfeb9ce.mjs"; +} from "./halo.dd87986d74909bc0.mjs"; const { app } = globalThis.comfyAPI?.app || {}; const { api } = globalThis.comfyAPI?.api || {}; diff --git a/web/resolution_control_deck.11b94ed50b65d983.js b/web/resolution_control_deck.6e1ff85eab697cf7.js similarity index 99% rename from web/resolution_control_deck.11b94ed50b65d983.js rename to web/resolution_control_deck.6e1ff85eab697cf7.js index ba957be..4623807 100644 --- a/web/resolution_control_deck.11b94ed50b65d983.js +++ b/web/resolution_control_deck.6e1ff85eab697cf7.js @@ -1,5 +1,5 @@ import { app } from "../../scripts/app.js"; -import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, setHaloNodeSize, calculateTierResolution, migrateResolutionGraph } from "./halo_resolution.83fe73faf4ac1a41.mjs"; +import { createHaloResolutionDeck, createHaloWidgetHost, haloMinimumNodeHeight, haloWidgetLayoutHeight, measureHaloContentHeight, setHaloNodeSize, calculateTierResolution, migrateResolutionGraph } from "./halo_resolution.7078744b005764b9.mjs"; const NODE_IDS = new Set("MATRIX_Resolution".split(",").filter(Boolean)); const RATIOS = {