docs+tooling: VAE user guide + versioning ref + 3 skills + preflight CI (#48)

* docs+tooling: VAE user guide + versioning ref + 3 skills + preflight CI

Documentation
- New user-guide tree: docs/user_guide/nodes/radiance_koolook_v2_3_3/
  with README, per-node pages for Easy_hdr_VAE_encode/decode, an
  encode/decode pairing guide (4 worked examples + ASCII connectivity
  diagram), and an HDR modes deep-dive (per-pixel comparison table).
  Folder name mirrors the code path under forks/radiance_koolook/versions/.
- New cross-cutting reference: docs/reference/versioning.md explains the
  three independent version axes (pack version, fork wrapper version,
  upstream pinned commit), when each one bumps, and which skill/doc
  owns it. Plus the v1/v2 node-ID suffix policy.
- Cross-links updated in docs/user_guide/README.md, docs/reference/
  README.md, docs/maintainers/README.md.

Skills (all under .claude/skills/)
- docs-sync — controlled find-and-replace for version tags across docs,
  code, and manifests; --scope and --dry-run flags.
- menu — lists all project skills with one-liners.
- preflight-release — companion doc for tools/preflight_release.py.

Pre-flight tooling
- tools/preflight_release.py — four checks, exit-code-driven for CI:
  (1) static AST extraction of NODE_CLASS_MAPPINGS keys (no torch needed),
  (2) stub-VAE dispatch test verifying all 5 rank/VAE-type branches,
  (3) drift check against ComfyUI-Manager's extension-node-map.json
      (catches the issue #44 phantom-nodes class of bug automatically),
  (4) saved-workflow regression check across tests/workflows/*.json.
- .github/workflows/ci.yml — new "Pre-flight Release" job runs checks
  1, 2, and 4 on every PR; check 3 (manager-meta) skipped pending the
  upstream issue #44 fix.
- tests/workflows/ — 2 starter fixtures (Depth-Anything3, Hires-FIX-wanT2V)
  + README documenting the contribution convention. Coverage grows as
  new fixtures are added; no code changes needed.

All four offline pre-flight checks pass on this branch. The
manager-meta check correctly identifies the 39 phantom IDs documented
in issue #44.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(preflight): satisfy ruff lint + bandit security gate

* fix(preflight): address blocking review findings

Three fixes from /review-pr feedback on this PR:

1. Sanitize tests/workflows/*.json — removed 4 unique absolute paths that
   leaked internal job/client info ('baconx/Toy-Hix/9110',
   'menneskehavn_3029', etc.). Replaced with placeholders like
   'path/to/sequence_a' and 'path/to/source_video'. Both fixtures still
   parse as valid JSON; grep for the old patterns returns zero hits.

2. Broaden the Koolook-ID heuristic regex from '^easy_[a-z]' to
   '^easy_[A-Za-z]'. The old pattern excluded 'easy_ImageBatch' (capital I
   after the underscore), which would have silently bypassed the
   workflow-fixture stability check for that node. Verified the new
   pattern correctly matches it.

3. Wrap check_dispatch() body in try/finally and track every sys.modules
   key the function injects in a list, so cleanup runs on the success
   path AND on any exception during exec_module / VAE-constructor
   execution. Previously only the three torch* stubs were popped, never
   the three _pf_pkg* entries — and even those weren't popped on
   exception paths. Verified zero leaked modules after the function
   returns.

The five dispatch test cases were factored into a helper
_check_dispatch_run_cases() so the try/finally wrapping stays focused on
module injection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(preflight): address remaining review findings

Follow-up to 7844037 — picks up all the HIGH / MEDIUM / LOW review
findings that were deferred. Pre-flight still passes locally.

HIGH
- Extract check_dispatch (~195 lines) to tools/_preflight_dispatch.py.
  Main script drops from 680 → 508 lines. Module exposes a single
  run(verbose, repo_root) -> (bool, list[str]) entry; main script keeps
  the [2/4] header + PASS/FAIL line.
- Install _HttpsOnlyRedirectHandler that re-validates each redirect
  hop's scheme. Closes the gap that urlopen would otherwise follow a
  30x to http:// or file:// silently. The Bandit B310 suppression
  comment now accurately describes the protection.
- check_static docstring now honestly states it only walks
  NODE_CLASS_MAPPINGS literal dicts and does NOT apply
  NAMESPACE_SUFFIX / SKIP_VERSION_SUFFIX logic. Notes that the workflow
  check (4) is the safety net that surfaces drift the day a non-skipped
  ID lands.
- check_workflows now FAILS when the fixtures dir exists with a README
  but contains no *.json files — previously silently SKIPPED. If
  someone deletes fixtures or typoes the dir, that's a regression in
  test coverage and should fail loudly.

MEDIUM
- check_manager_meta validates the upstream entry shape before diffing.
  Previous code silently produced an empty set if the schema changed,
  reporting every one of our IDs as 'missing'. Now SKIPs with a clear
  message when shape is unexpected.
- check_workflows accumulates total_koolook_refs inside the main loop;
  drops the redundant second-pass that re-read every fixture file just
  to compute the summary count.
- _extract_workflow_node_types uses explicit detection: a canvas-format
  file with nodes: null no longer falls through to API-format and pulls
  class_type from unrelated keys.
- check_static return type simplified from (bool, set, list) to
  (bool, set). Third element was discarded at every call site.
- check_workflows now does two-pronged Koolook-ID matching: heuristic
  (catches drift even if static missed an ID) plus actual_ids (catches
  IDs that don't fit the heuristic, e.g. a future Koolook node without
  Easy / easy_ / koolook in its name).

LOW
- ANSI escape codes named in a constants dict (_ANSI["green"] etc.)
  instead of bare string literals.
- Removed dead 'cases: list[tuple[str, callable]] = []' initializer
  (was shadowed three statements later).
- check_static's per-ID listing now gated behind --verbose, matching
  check_dispatch and check_workflows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-04 17:31:36 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent b7b34de7e2
commit cc6bc47866
18 changed files with 3481 additions and 16 deletions
+186
View File
@@ -0,0 +1,186 @@
---
name: docs-sync
description: Find-and-replace a version tag (or any string token) across docs, code constants, and manifests in one controlled pass. Use when bumping a fork from one version to another (e.g. v2_3_3 -> v2_4_0), renaming a node ID across the repo, or generally propagating a string change everywhere it's referenced. Takes <old-tag> and <new-tag>; optional --scope flag (all / docs / code) and --dry-run. Always shows every match before applying and re-verifies post-edit.
---
# docs-sync
A controlled find-and-replace across the repo for keeping docs, manifests,
and code constants in sync when something tagged by a version string moves.
The skill does **not** rename folders or move files on its own — that would
be too easy to break things with. It only changes the **content of files**
that already exist. Folder renames (e.g. `versions/v2_3_3/` -> `versions/v2_4_0/`)
are a deliberate manual `git mv` step that this skill expects you to run
either before or after, and it'll re-grep to flag any stale references
after the move.
## When to use it
- Bumping a fork's version tag everywhere it appears (most common use).
- Renaming a node ID and propagating that to docs / changelog / manifests.
- Any string-level rename where you'd otherwise grep manually and worry
about missing one.
## When NOT to use it
- Refactoring code structure (use a code-aware tool like an IDE refactor).
- Renaming things that have *non-textual* references (e.g. a JSON key that
some tool computes by hash) — those need targeted edits, not a string
sweep.
## Inputs
```
docs-sync <old-tag> <new-tag> [--scope all|docs|code|manifests] [--dry-run]
```
- `<old-tag>` — the string to find. Required. Can be a version like `v2_3_3`,
a display version like `v2.3.3`, a node ID like `Easy_hdr_VAE_encode`, or
a free-form tag.
- `<new-tag>` — the replacement string. Required.
- `--scope` — optional. Restrict where the find-and-replace applies:
- `all` (default): docs, code, and manifests.
- `docs`: only `docs/` and `README.md`.
- `code`: only `*.py` source files.
- `manifests`: only `pyproject.toml`, `forks/*.yaml`, `forks/*.md`.
- `--dry-run` — show every match without applying changes.
## Common usage patterns
### Bumping a fork version
When you're bumping `radiance_koolook` from v2.3.3 to v2.4.0, run the
skill **twice** to cover the two textual representations the codebase
uses:
```
docs-sync v2_3_3 v2_4_0 # underscored form (folder names, NAMESPACE_SUFFIX, docs paths)
docs-sync v2.3.3 v2.4.0 # dotted form (DISPLAY_SUFFIX, prose in docs and CHANGELOG)
```
You can also run with `--dry-run` first to see the scope of changes
before committing. The skill will not rename the actual `versions/v2_3_3/`
folder — do that manually with `git mv` after the string sweep is clean.
### Renaming a node ID after a v2 break
Before any tag change, every reference to the old ID gets updated:
```
docs-sync Easy_hdr_VAE_encode Easy_hdr_VAE_encode_v2 --scope docs
```
The `--scope docs` is important here — you almost certainly do *not*
want to update the Python `NODE_CLASS_MAPPINGS` key, because that's the
*old* ID that needs to remain registered for back-compat. Code edits
for new ID registration belong in a focused PR, not a sweep.
## Procedure
1. **Validate inputs.**
- Ensure `<old-tag>` and `<new-tag>` are non-empty and distinct.
- Reject if `<old-tag>` looks dangerous as a substring (very short
literal like a single letter or common word). Ask the user to
confirm if `<old-tag>` is shorter than 4 characters.
2. **Search.** Use ripgrep (via the agent's Grep tool):
- Scope `all`: search the whole worktree, excluding `__pycache__/`,
`.git/`, `node_modules/`, and binary files (default ripgrep behavior).
- Scope `docs`: restrict to `docs/**/*.md`, `README.md`, top-level `*.md`.
- Scope `code`: restrict to `**/*.py`.
- Scope `manifests`: restrict to `pyproject.toml`, `forks/forks_manifest.yaml`,
`forks/THIRD_PARTY.md`, `forks/**/UPSTREAM_PIN.yaml`.
3. **Categorize matches.** Group hits by file:
- Code (`.py` files)
- Manifests (`.yaml`, `.toml`, `THIRD_PARTY.md`)
- Docs (`docs/**/*.md`, `*.md`)
- Other (anything not in the above)
4. **Present the list.** Show every match with file path + line number +
snippet of the line. Group by category. Total count at the bottom.
5. **Confirm.** Print: "Apply replacement of `<old>` -> `<new>` to N
occurrences across M files? (y/N — or use --dry-run to preview only)"
Wait for user confirmation. Skip this step if `--dry-run`.
6. **Apply.** For each file with matches, do an in-place text replacement.
Use the agent's Edit tool with `replace_all=true` if multiple hits in
one file, otherwise per-occurrence with enough context to be unique.
7. **Verify.** Re-grep for `<old-tag>` in the same scope. Report any
remaining occurrences (these are likely intentional — e.g. CHANGELOG
entries describing the *previous* version, which should reference the
old tag literally and not get rewritten). Highlight them as
"intentional remainders to keep" so the user can confirm.
8. **Report.** Final summary:
- Files modified: N
- Replacements applied: M
- Remaining occurrences (if any): list
- Suggested next steps (folder renames if applicable, commit message,
dev-sync if relevant).
## Safety guardrails
- **Never delete or move files.** The skill only edits content within
existing files.
- **Never auto-commit.** All changes stay in the working tree for the
user to review with `git diff` before committing.
- **Word-boundary intent.** When `<old-tag>` is short or could be a
substring of unrelated tokens (e.g. `v2` could match `v2.3.3` and
`v2_pipeline_test`), prompt the user to use a more specific tag or
add anchoring characters (e.g. `v2_3_3` rather than `v2`).
- **Skip changelog entries describing past releases.** When `--scope all`
finds matches in `CHANGELOG.md` under sections that describe a
*previous* release (e.g. `## [0.1.5]` or `## [0.2.0]`), default to
*not* rewriting those — they're historical record. Surface them as
"found but kept as historical" in the report.
## Examples
### Example 1 — bump radiance_koolook from v2.3.3 to v2.4.0
```
docs-sync v2_3_3 v2_4_0
```
Expected scope of changes:
- `forks/radiance_koolook/__init__.py` (import path)
- `forks/radiance_koolook/versions/v2_3_3/__init__.py` (NAMESPACE_SUFFIX
constant) — but only after the folder is renamed via `git mv`
- `forks/THIRD_PARTY.md` (section heading + body references)
- `forks/forks_manifest.yaml` (notes field)
- `docs/user_guide/nodes/radiance_koolook_v2_3_3/` (path references in
page bodies — but folder rename via `git mv` happens manually)
- `CHANGELOG.md` `[Unreleased]` section (new version note)
Then a separate run for the dotted form:
```
docs-sync v2.3.3 v2.4.0
```
Picks up:
- `DISPLAY_SUFFIX = " (Koolook v2.3.3)"` in the moved `__init__.py`
- Prose in docs that says "v2.3.3 baseline"
- THIRD_PARTY.md prose
- CLAUDE.md fork policy section
### Example 2 — preview a node-ID rename
```
docs-sync Easy_hdr_VAE_encode Easy_hdr_VAE_encode_v2 --scope docs --dry-run
```
Reports every doc page that mentions the old ID without making any
changes. Useful before deciding whether to do the rename in code first.
## Related skills
- `bump-fork-version` *(planned, not yet built)* — orchestrates the full
fork bump including folder rename, constant updates, and CHANGELOG
entry. Will internally invoke `docs-sync` for the string-sweep step.
- `add-external-fork` — for setting up a brand-new fork, not bumping an
existing one.
+76
View File
@@ -0,0 +1,76 @@
---
name: menu
description: List all project-level skills available under .claude/skills/ with their one-line descriptions, so the maintainer can browse what tools are available without remembering each one. Use when the user types /menu, "list skills", "what skills do we have", or "show me the skill index". This is the project-specific inventory; user-level skills under ~/.claude/skills/ are listed separately when relevant.
---
# menu
Quick inventory of the project-level skills under `.claude/skills/`.
Run when the user wants a one-look overview of what's available without
having to remember each skill by name.
## Trigger phrases
- `/menu`
- "list skills"
- "what skills do we have"
- "show me the skill index"
- "skill menu"
## Procedure
1. **List skills under `.claude/skills/`.** Use the agent's Glob or Bash
tool to enumerate `.claude/skills/*/SKILL.md`.
2. **Parse each SKILL.md frontmatter.** YAML frontmatter is delimited by
`---` lines at the top of each file. Extract:
- `name` — the skill's canonical name.
- `description` — the one-liner used for trigger matching. Often
long; truncate to the first sentence (text up to the first period
or 200 characters, whichever comes first) for the menu display.
3. **Render as a table.** Markdown table with columns:
- **Name** — the skill name (also functions as a link if rendered in
a viewer that supports relative links).
- **One-liner** — the truncated description.
- **How to invoke** — the trigger phrase. Default form is `/<name>`
(e.g. `/docs-sync`) or the first phrase from the description if
the skill has more specific triggers.
4. **(Optional) List user-level skills.** If the user asks for "all
skills" or the project list is short (< 3 skills), also enumerate
`~/.claude/skills/*/SKILL.md` (or the equivalent on Windows) under
a separate "User-level skills" heading. Skip this section by default
to keep the output tight.
5. **End with a short reminder** of how to add new skills:
*"To add a new project skill: create `.claude/skills/<name>/SKILL.md`
with YAML frontmatter (`name:`, `description:`) and a markdown body
describing the procedure. Re-run `/menu` to see it appear here."*
## Output format
Match this layout (substitute real values):
```
## Project skills
| Name | One-liner | Invoke |
|---|---|---|
| docs-sync | Find-and-replace a version tag across docs, code, and manifests in one controlled pass. | `/docs-sync <old> <new>` |
| add-external-fork | Set up an external upstream repository as a pinned reference checkout under ../ComfyUI-Forks. | `/add-external-fork` |
| license-pre-check | Run a license-compatibility audit before incorporating any third-party code. | `/license-pre-check` |
| menu | List all project-level skills with their one-line descriptions. | `/menu` |
To add a new project skill: create `.claude/skills/<name>/SKILL.md`
with YAML frontmatter and a markdown body. Re-run `/menu` to see it.
```
## Safety guardrails
- **Read-only.** This skill never edits files.
- **No external calls.** Reads only the local filesystem.
## Related skills
- (none — this skill is purely an indexer)
+138
View File
@@ -0,0 +1,138 @@
---
name: preflight-release
description: Run the project's release-blocking sanity checks before merging or shipping. Verifies (1) all *.py files parse and the registered node-ID list can be statically extracted, (2) the VAE wrapper's rank/VAE-type dispatch branches still work via a stub-VAE roundtrip, (3) ComfyUI-Manager's upstream extension-node-map.json matches our actual node IDs (catches phantom-nodes drift like issue #44), and (4) every Koolook node ID referenced in tests/workflows/*.json fixtures is still registered (catches accidental ID rename / removal that would break saved workflows). Use before cutting a release, before merging a PR that touches node IDs or fork structure, or whenever the user mentions "preflight" / "release sanity check" / "are we good to ship". Wraps tools/preflight_release.py.
---
# preflight-release
Project-specific release-blocking sanity check. Runs four independent
checks via `tools/preflight_release.py`; any failure means "do not ship
yet, investigate first."
This skill is distinct from the user-level `preflight` skill (which is
about repo infrastructure — branch protection, labels, git state).
This one is about **code/metadata correctness**.
## When to use it
- **Before cutting a release.** Run it after the release-prep PR is
open, before triggering the publish workflow.
- **Before merging any PR that touches node IDs or fork structure.** A
changed `NODE_CLASS_MAPPINGS` or a renamed fork directory is exactly
the kind of edit that can silently break saved workflows.
- **After running [`docs-sync`](../docs-sync/SKILL.md) on a fork-version
bump.** The string-sweep can leave references stale if anything is
missed; pre-flight catches it before merge.
- **On demand** when the user asks "are we good to ship?" /
"run preflight" / "release sanity check".
## How to invoke
Run the wrapped script directly:
```bash
python tools/preflight_release.py # all four checks
python tools/preflight_release.py -v # verbose per-file output
python tools/preflight_release.py --check static --check workflows
python tools/preflight_release.py --skip manager-meta # offline mode
```
Exit code 0 = ship-clean. Exit code 1 = fix something. Exit code 2 =
invalid CLI usage.
## What each check actually does
### 1. `static` — AST extraction of NODE_CLASS_MAPPINGS
- Walks every `*.py` in the repo (excluding `__pycache__`, `.venv`,
`upscaler_FIX/`, `nuke_CAM_exporter/`).
- AST-parses each one. **No imports** — works without torch / ComfyUI
installed, which means CI doesn't need to install heavy deps.
- Collects the literal string keys of every `NODE_CLASS_MAPPINGS = {...}`
literal-dict assignment found across the repo.
- Reports any parse errors with file:line.
**Limitation:** doesn't follow runtime-computed mappings (dict comprehensions,
`_namespace_mappings()` calls, etc.). For the current Radiance Koolook
v2_3_3 fork this happens to match reality because all exposed IDs are
in `SKIP_VERSION_SUFFIX`, so the source-dict keys equal the registered
keys. Future fork additions that rely on suffix mangling will need the
extraction extended.
### 2. `dispatch` — VAE rank/VAE-type branches
- Mocks `torch` with a `FakeTensor` stub that mimics just enough of the
tensor API to drive the wrapper's dispatch logic (no real math).
- Loads `forks/radiance_koolook/versions/v2_3_3/{nodes_vae,color_helpers}.py`
via `importlib`.
- Runs five test cases through `Easy_hdr_VAE_encode/decode`:
1. 4-D image input + 2-D image VAE → `path=image`
2. 5-D video input + 2-D image VAE → `path=video-iter` (frame iteration)
3. 5-D video input + 3-D-aware video VAE → `path=video-3d` (passthrough)
4. 5-D latent + 2-D VAE decode → frame iteration, 4-D output
5. 4-D latent decode → standard path
- Asserts each case produces the expected shape + dispatch path string in
`debug_info`.
Catches regressions to the rank-handling logic that the v0.2.0 release
introduced.
### 3. `manager-meta` — extension-node-map.json drift
- Fetches `https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/extension-node-map.json`.
- Looks up our entry (keyed on `https://github.com/malkuthro/ComfyUI-Koolook`).
- Diffs the upstream node-ID list against the AST-extracted list from check 1.
- Reports **phantom IDs** (in upstream but not in our code — Manager has
stale data) and **missing IDs** (in our code but not in upstream — our
code is ahead of the upstream metadata).
**Network-dependent.** Skipped automatically when offline (e.g. `--skip
manager-meta`, or in CI where it's currently always skipped per
`.github/workflows/ci.yml` because the drift is tracked separately as
issue #44 and fails will be noisy until the upstream PR lands).
When the upstream issue is resolved, remove the `--skip manager-meta`
from the CI job and let it become a release blocker.
### 4. `workflows` — fixture node-ID stability
- Walks `tests/workflows/*.json`.
- For each fixture, parses the JSON and extracts every node `type` (handles
both ComfyUI canvas format with a top-level `nodes` array, and API
format where each top-level dict value has a `class_type` key).
- Filters for IDs that look like Koolook IDs: matches `(?i)koolook`,
`^Easy[A-Z_]`, or `^easy_[a-z]`.
- Asserts every Koolook ID found in the fixtures is still in the
AST-extracted set from check 1.
If a fixture references a node ID we no longer register, **the saved
workflow would break for any user who has it**. The pre-flight fails
loudly so the rename gets either reverted, aliased for back-compat, or
treated as a `_v2`-suffix breaking change with a deprecation cycle.
Adding more fixtures over time grows coverage without code changes:
drop a new `*.json` into `tests/workflows/`, the script picks it up
automatically. See `tests/workflows/README.md` for the contribution
convention.
## How failures usually get fixed
| Check | Common failure | Usual fix |
|---|---|---|
| static | Syntax error in a `.py` file | Fix the syntax. ruff lint should also catch this. |
| static | "no NODE_CLASS_MAPPINGS literal dicts found" | Probably an accidental refactor that broke the registration; revert or restructure. |
| dispatch | Assertion fails on one of the five branches | Look at the changed file in `forks/radiance_koolook/versions/v2_3_3/`. Trace the dispatch logic in `nodes_vae.py:Easy_hdr_VAE_{encode,decode}`. The branch that broke tells you which rank/VAE combination regressed. |
| manager-meta (phantom IDs) | Upstream Manager metadata has IDs we don't register | File / update issue #44; not a code fix on our side. Skip the check. |
| manager-meta (missing IDs) | Our code has IDs upstream Manager doesn't list yet | Wait for the next upstream "update DB" run, or open an upstream PR. Skip the check until it propagates. |
| workflows | Fixture references a node ID no longer registered | (a) Add the old ID back as a `NODE_CLASS_MAPPINGS` alias pointing at the renamed class, OR (b) update the fixture if the rename was intentional + announced + the user was warned, OR (c) introduce a `_v2` suffix on the new node and start a deprecation cycle. |
## Companion docs
- `tools/preflight_release.py` — the actual implementation.
- `tests/workflows/README.md` — how to add new fixtures.
- `docs/maintainers/releasing.md` — the release procedure that this
pre-flight feeds into.
- `docs/reference/versioning.md` — the three version axes that
determine when each check matters.
- Issue #44 — the canonical example of the kind of drift `manager-meta`
detects.
+17
View File
@@ -44,3 +44,20 @@ jobs:
- name: Bandit scan
run: bandit -r . -x ./.venv,./forks,./upscaler_FIX,./nuke_CAM_exporter -ll
preflight:
name: Pre-flight Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Run pre-flight (static + dispatch + workflow fixtures)
# manager-meta is skipped on PRs because the upstream node-map drift
# is owned by an external repo (ltdrdata/ComfyUI-Manager) and is
# tracked separately via issue #44. Re-enable by removing --skip
# once that issue is resolved upstream.
run: python tools/preflight_release.py --skip manager-meta -v
+5
View File
@@ -18,9 +18,14 @@ and should be kept in sync with these prose docs.
| [`workflows-sidebar.md`](workflows-sidebar.md) | The "Workflows" section of the same sidebar. Save canvas / save selection / directories / archive on collision / load with named tab. Storage shape, action semantics, reset procedure. |
| [`dev-iteration-loop.md`](dev-iteration-loop.md) | The fast iteration pattern we used to build the sidebar — the dev-sync loop, agent trigger phrases (`go`, `dev-sync`, paste-JSON), what survives across re-syncs vs what doesn't, push/publish gates. Read this when returning to UI work after a break. |
> **Read this first if you're confused about version numbers:**
> [`../reference/versioning.md`](../reference/versioning.md) explains the three independent version axes in this codebase (pack version, fork wrapper version, upstream pinned commit) and which skill/doc owns each one. Worth a one-time read before starting any version-bump work.
## Companion agent skills
| Skill (in `.claude/skills/`) | Mirrors this doc |
|---|---|
| `license-pre-check` | Runs *before* any third-party fork; refuses to proceed on incompatible licenses. The principles also apply when a human is doing it manually. |
| `add-external-fork` | The `forks/` workflow — clone outside MAIN, register in `forks_manifest.yaml`, namespace the IDs. Phase 0 calls `license-pre-check`. |
| `docs-sync` | Find-and-replace a version tag (or any string token) across docs, code constants, and manifests in one controlled pass. Used during fork wrapper version bumps. |
| `menu` | Lists all project skills with one-liners — quick inventory when you can't remember what's available. |
+1
View File
@@ -8,6 +8,7 @@ search for to answer a "what is X?" or "which nodes do Y?" question.
| File | What it is |
|---|---|
| [`glossary.md`](glossary.md) | Plain-English definitions for the terms used in this repo's workflow (MAIN, fork, namespace suffix, sibling project, etc.) |
| [`versioning.md`](versioning.md) | The three independent version axes in this codebase (pack version, fork wrapper version, upstream pinned commit) — what each one means, when each one bumps, and which skill/doc owns it |
| [`comfyUI_nodes-inventory_by_NAME.png`](comfyUI_nodes-inventory_by_NAME.png) | Screenshot inventory of all currently-registered nodes by display name (helpful for matching what you see in ComfyUI back to the source files) |
## When to add a reference doc here
+117
View File
@@ -0,0 +1,117 @@
# Versioning — three numbers, three meanings
This codebase carries **three independent version numbers** that often get
confused. They mean different things, bump on different schedules, and live
in different places.
## At a glance
| Axis | What it is | Where it lives | Example |
|---|---|---|---|
| **Pack version** | The Koolook release that ships to GitHub + Comfy Registry. Semver. | [`pyproject.toml`](../../pyproject.toml) `version = "X.Y.Z"`, [`CHANGELOG.md`](../../CHANGELOG.md) `## [X.Y.Z]` | `0.2.0` |
| **Fork wrapper version** | A slot under `versions/` that holds Koolook's modified code for a particular upstream baseline. Underscored. | `forks/<fork>/versions/vX_Y_Z/` directory name + the `NAMESPACE_SUFFIX` constant inside it | `v2_3_3` |
| **Upstream pinned commit** | The exact commit of the upstream third-party repo that the fork wrapper is matched to. SHA. | [`forks/<fork>/versions/vX_Y_Z/UPSTREAM_PIN.yaml`](../../forks/radiance_koolook/versions/v2_3_3/UPSTREAM_PIN.yaml), [`forks/forks_manifest.yaml`](../../forks/forks_manifest.yaml), [`forks/THIRD_PARTY.md`](../../forks/THIRD_PARTY.md) | `f262f47ddfda01ece154bf80c22769b1e4cef795` |
A current snapshot reads: *"Koolook **0.2.0** ships the **v2_3_3** Radiance
fork wrapper, which is pinned to upstream Radiance commit **f262f47**."*
All three are unrelated numbers that happen to all describe the same
release.
## When each one bumps
### Pack version (`0.2.0` → `0.2.1` / `0.3.0` / `1.0.0`)
Bumps on **every release of the Koolook pack itself**. Drives the Comfy
Registry publish workflow. Follow [`docs/maintainers/releasing.md`](../maintainers/releasing.md)
for the canonical procedure.
- **Patch** (`0.2.0 → 0.2.1`): bug fixes, internal refactors, no surface change.
- **Minor** (`0.2.0 → 0.3.0`): new nodes, new node options, anything that adds surface but doesn't break existing workflows.
- **Major** (`0.2.0 → 1.0.0`): only after a v2-style breaking change has shipped (a removed-after-deprecation node ID, an INPUT_TYPES change a saved workflow can't tolerate, etc.).
### Fork wrapper version (`v2_3_3` → `v2_4_0` / `v2_3_4`)
Bumps when **upstream releases a new version we want to track** AND the
new upstream version requires meaningfully different wrapper code.
Two patterns, decided per-fork:
- **In-place upgrade** (used by Radiance Koolook today). All exposed node
IDs are listed in `SKIP_VERSION_SUFFIX` so the user-facing IDs are
stable across versions (e.g. `Easy_hdr_VAE_encode` stays as itself
whether the wrapper is `v2_3_3` or `v2_4_0`). When upstream bumps, the
whole `versions/v2_3_3/` directory gets renamed to `versions/v2_4_0/`
in-place; there's no parallel coexistence because both versions would
collide on the same ID. Saved workflows continue loading because the
ID didn't change.
- **Versioned coexistence**. Node IDs carry the `__koolook_vX_Y_Z`
suffix (the default behavior unless explicitly added to
`SKIP_VERSION_SUFFIX`). When upstream bumps, the new `versions/v2_4_0/`
is added alongside the old one. Both are registered simultaneously
for one release cycle. Old workflows keep loading via the suffixed
old ID; new workflows can adopt the new suffixed ID. The old version
is removed in the next release.
### Upstream pinned commit (`f262f47` → some new SHA)
Bumps when **we re-sync the wrapper against a newer upstream commit**.
Recorded in:
- `forks/<fork>/versions/<vX_Y_Z>/UPSTREAM_PIN.yaml` (the per-version pin)
- `forks/forks_manifest.yaml` (the project-level fork inventory)
- `forks/THIRD_PARTY.md` (the human-readable attribution + change notes)
A new pinned commit may or may not require a new fork wrapper version —
small upstream patches can land at the same wrapper slot if our wrapper
code doesn't have to change. Bigger upstream releases usually warrant a
new wrapper version too.
## Public node-ID versioning (the `_v2` rule)
Independent of the three axes above, individual node IDs can carry a
public version suffix when a breaking change forces it:
- Original ID (no suffix): `Easy_hdr_VAE_encode`. Stays this forever.
- Bumped ID after a breaking change: `Easy_hdr_VAE_encode_v2`. Only
introduced when the original would otherwise break saved workflows.
- Both IDs register simultaneously for **one full release cycle** (the
old one displays as `Easy HDR VAE Encode (Koolook, v1 — please migrate)`
during this window).
- Old ID is dropped one release later. Pack version goes major (`1.0.0`)
to signal the breakage.
This is the rare path. Most version changes don't need it because either
the change isn't actually breaking, or the fork wrapper version mechanism
above already handles the back-compat.
## Which skill / doc owns which axis
| Axis | Skill | Doc |
|---|---|---|
| Pack version | (none, manual via release PR) | [`docs/maintainers/releasing.md`](../maintainers/releasing.md) |
| Fork wrapper version | [`docs-sync`](../../.claude/skills/docs-sync/SKILL.md) (string-sweep), [`add-external-fork`](../../.claude/skills/add-external-fork/SKILL.md) (initial setup) | [`forks/README.md`](../../forks/README.md), [`docs/maintainers/node-versioning.md`](../maintainers/node-versioning.md) |
| Upstream pinned commit | (none — manual edit to `UPSTREAM_PIN.yaml` + `forks_manifest.yaml`) | [`forks/THIRD_PARTY.md`](../../forks/THIRD_PARTY.md) |
| Node-ID `_v2` suffix | (none — manual code change with deprecation alias) | (this document) |
## Example — a hypothetical Radiance v2.4.0 release
Suppose upstream Radiance ships v2.4.0. We re-sync and the wrapper code
changes only slightly — same node IDs, same interface. The bumps:
- **Upstream pinned commit:** `f262f47…` → whatever the v2.4.0 release commit is. Update `UPSTREAM_PIN.yaml`, `forks_manifest.yaml`, `THIRD_PARTY.md`.
- **Fork wrapper version:** `v2_3_3` → `v2_4_0`. Rename `versions/v2_3_3/` to `versions/v2_4_0/`. Update `NAMESPACE_SUFFIX` and `DISPLAY_SUFFIX` constants inside. Update parent `__init__.py` import. Rename matching docs folder `docs/user_guide/nodes/radiance_koolook_v2_3_3/` → `radiance_koolook_v2_4_0/`. Update inline references via [`docs-sync v2_3_3 v2_4_0`](../../.claude/skills/docs-sync/SKILL.md).
- **Pack version:** `0.2.0` → `0.3.0`. Cut a release PR per [`releasing.md`](../maintainers/releasing.md).
- **Node IDs:** unchanged (stable IDs). No `_v2` suffix introduced. Saved workflows keep loading.
If the v2.4.0 wrapper required a *behaviorally* breaking change to one of
the node IDs — say `Easy_hdr_VAE_encode` now returns a different shape
that downstream samplers can't handle — only then would we add
`Easy_hdr_VAE_encode_v2` and start the deprecation cycle.
## See also
- [`docs/maintainers/releasing.md`](../maintainers/releasing.md) — pack version release procedure.
- [`docs/maintainers/node-versioning.md`](../maintainers/node-versioning.md) — the rules for safely changing `INPUT_TYPES` / `RETURN_TYPES` / class names without breaking saved workflows.
- [`forks/THIRD_PARTY.md`](../../forks/THIRD_PARTY.md) — upstream attribution and change notes for each fork.
- [`forks/forks_manifest.yaml`](../../forks/forks_manifest.yaml) — machine-readable fork inventory.
- [`.claude/skills/docs-sync/SKILL.md`](../../.claude/skills/docs-sync/SKILL.md) — the find-and-replace skill for fork-version bumps.
+44 -16
View File
@@ -1,26 +1,54 @@
# User guide
# User Guide
End-user documentation for the nodes shipped by `ComfyUI-Koolook`. Search for a node by display name in ComfyUI (type "koolook" in the node-add menu) and see the corresponding guide here.
End-user documentation for nodes shipped by `ComfyUI-Koolook`. Pages
mirror the ComfyUI category each node lives under, so the structure here
matches what you see when you type "koolook" in the node-add menu.
## Available guides
Each per-node page includes a **Source** block at the top that records
whether the node is Koolook-native or imported from a fork — so fork
attribution is visible without needing a fork-shaped directory tree.
*(Stub — guides are added as they're written. The starter image in [`img/Easy_Image_Batch_v0.1.5.png`](img/Easy_Image_Batch_v0.1.5.png) is the first one queued.)*
## Browse by category
| ComfyUI menu | Status | Nodes |
|---|---|---|
| `Koolook/Pipeline` | 🚧 page TBD | `EasyAIPipeline` |
| `Koolook/Image` | 🚧 page TBD | `EasyResize_Koolook`, `easy_ImageBatch` |
| `Koolook/Camera` | 🚧 page TBD | `KoolookLoadCameraPosesAbsolute` |
| `Koolook/Wan_Video` | 🚧 page TBD | `EasyWan22Prompt` |
| `Koolook/VAE` | ✅ [`nodes/radiance_koolook_v2_3_3/`](nodes/radiance_koolook_v2_3_3/) | `Easy_hdr_VAE_encode`, `Easy_hdr_VAE_decode` |
## Cross-cutting concepts
Pages here cover ideas that span more than one node:
- [VAE encode/decode pairing](nodes/radiance_koolook_v2_3_3/encode_decode_pairing.md) — which
encoder fields must match the decoder for a clean roundtrip, with
worked examples for SDR / HDR / cinema-log / video workflows.
- [HDR modes deep dive](nodes/radiance_koolook_v2_3_3/hdr_modes.md) — what `Clip (SDR)`,
`Soft Clip`, `Compress (Log)`, and `Passthrough` actually do to your
pixels before they hit the VAE, with per-pixel worked examples.
## When to add a guide
If a node has any of:
- non-obvious input semantics (e.g. aspect-ratio strings, frame indexing conventions),
- a workflow recipe more useful than the per-input tooltips,
- a known limitation or compatibility caveat,
A node deserves a page if it has any of:
→ create a per-node markdown file here, name it after the canonical node ID
(e.g. `easy_image_batch.md`, `easy_resize_koolook.md`), and add it to the
"Available guides" table above.
- Non-obvious input semantics (e.g. aspect-ratio strings, frame indexing
conventions).
- A workflow recipe more useful than the per-input tooltips.
- A known limitation or compatibility caveat.
- An encode/decode or input/output pairing relationship with other nodes.
Screenshots and helper images go in [`img/`](img/), named to match the file
they're embedded in (e.g. `Easy_Image_Batch_v0.1.5.png`).
Convention:
## Where to find the node IDs
- One markdown file per canonical node ID, named after the ID
(`easy_hdr_vae_encode.md`, `easy_image_batch.md`).
- Place it in the folder that matches the node's ComfyUI category.
- Screenshots and helper images go in [`img/`](img/), named to match the
page they're embedded in (e.g. `easy_hdr_vae_encode_pairing.png`).
The canonical list is in the project [`README.md`](../../README.md) and
[`reference/`](../reference/).
## Where to find the canonical node ID list
The repo [`README.md`](../../README.md) lists all currently-registered
node IDs and display names. The historical inventory (including removed
nodes) is in the [`CHANGELOG.md`](../../CHANGELOG.md).
@@ -0,0 +1,54 @@
# Koolook/VAE — User Guide
Cinema-grade VAE encode/decode with HDR / wide-gamut color awareness.
Designed to work with image VAEs (SD1.5, SDXL, Flux) **and** video VAEs
(Wan 2.2, Hunyuan, CogVideoX, LTX) using a single rank-aware code path.
## Pages in this folder
| Page | Use it to … |
|---|---|
| [`easy_hdr_vae_encode.md`](easy_hdr_vae_encode.md) | Look up what each input on the encoder does, and how its outputs are wired |
| [`easy_hdr_vae_decode.md`](easy_hdr_vae_decode.md) | Same, for the decoder |
| [`encode_decode_pairing.md`](encode_decode_pairing.md) | Get the connectivity right between encoder and decoder for a clean roundtrip — which fields must match, which are independent, with worked examples for SDR / HDR / cinema-log / Wan 2.2 video |
| [`hdr_modes.md`](hdr_modes.md) | Understand the four HDR modes (`Clip (SDR)`, `Soft Clip`, `Compress (Log)`, `Passthrough`) — what each one does to pixels before hitting the VAE, with per-pixel worked examples |
## Quick mental model
```
pixels (any of 12 color spaces, any rank)
│
▼
┌──────────────────────────┐
│ Easy_hdr_VAE_encode │ source_space → linear → exposure
│ │ → hdr_mode-dependent gamma/log encoding
│ │ → vae.encode (with sample/mean/mode)
└──────────────────────────┘
│ samples (LATENT)
│ debug_info (STRING — per-run diagnostic)
│ alpha (IMAGE — for round-tripping alpha around the VAE)
│
▼
KSampler (or whatever) — optional
│
▼
┌──────────────────────────┐
│ Easy_hdr_VAE_decode │ vae.decode → 5-D-to-4-D normalization
│ │ → hdr_mode-dependent recovery (log
│ │ decompress + soft-shoulder if Log mode)
│ │ → exposure_adjust (linear)
│ │ → linear → target_space
└──────────────────────────┘
│ image (IMAGE)
│ debug_info (STRING)
```
## Source
| Aspect | Value |
|---|---|
| Origin | Koolook fork of [fxtdstudios/radiance](https://github.com/fxtdstudios/radiance) v2.3.3 (commit `f262f47`) |
| Slim port | Drops upstream's 4K cosine-blend tile engine that was incompatible with video VAEs |
| Local path | [`forks/radiance_koolook/versions/v2_3_3/`](../../../../forks/radiance_koolook/versions/v2_3_3/) |
| Attribution + change notes | [`forks/THIRD_PARTY.md`](../../../../forks/THIRD_PARTY.md) |
| License | GPL-3.0 (inherited from upstream Radiance) |
@@ -0,0 +1,107 @@
# Easy HDR VAE Decode (Koolook)
| Aspect | Value |
|---|---|
| Display name | `Easy HDR VAE Decode (Koolook)` |
| Node ID | `Easy_hdr_VAE_decode` |
| ComfyUI category | `Koolook/VAE` |
| Source | Koolook fork of fxtdstudios/radiance v2.3.3 (slim port — drops upstream's 4K cosine-blend tile engine) |
| Source file | [`forks/radiance_koolook/versions/v2_3_3/nodes_vae.py`](../../../../forks/radiance_koolook/versions/v2_3_3/nodes_vae.py) |
| Pairs with | [`Easy_hdr_VAE_encode`](easy_hdr_vae_encode.md) |
| Cross-cutting | [HDR modes](hdr_modes.md), [Encode/decode pairing](encode_decode_pairing.md) |
## What it does
Decodes a `LATENT` back into pixel space, with cinema-grade colour-space
output and HDR awareness. Mirrors `Easy_hdr_VAE_encode` and accepts both
4-D image latents and 5-D video latents from 3-D-aware VAEs (Wan 2.2,
Hunyuan, CogVideoX, LTX).
For 3-D-aware VAEs that return a 5-D `(B, F, H, W, C)` tensor, the
decoder unconditionally reshapes it to ComfyUI's standard 4-D
`(B*F, H, W, C)` IMAGE batch — so `SaveImage` and other downstream
IMAGE-typed nodes see one IMAGE per frame and write `_00001.png`,
`_00002.png`, … as expected.
## Inputs
### Required
| Input | Type | Description |
|---|---|---|
| `samples` | `LATENT` | The latent to decode. Same VAE that produced this latent must be wired to `vae`. |
| `vae` | `VAE` | The VAE to decode through. **Must match the encoder's VAE.** |
| `target_space` | enum (12 options) | The colour space the output IMAGE should be in. Same set of 12 values as the encoder's `source_space`. |
### Optional
| Input | Type | Default | Description |
|---|---|---|---|
| `exposure_adjust` | `FLOAT` | `0.0` | Post-decode exposure adjustment in stops, applied **in linear space**. Range: `-10.0` to `+10.0`. |
| `hdr_mode` | enum (4 options) | `Clip (SDR)` | How to interpret the VAE's output before linearising. **Must match the encoder's `hdr_mode`** for clean roundtrips. `Compress (Log)` triggers the recovery pipeline (soft-shoulder + log-domain highlight denoise + log-curve decompression). Other modes treat the VAE output as sRGB-gamma data. See [HDR modes deep dive](hdr_modes.md). |
| `source_space` | enum (12 options) | `Linear` | **Only relevant when `hdr_mode = Compress (Log)`.** Tells the decoder which log curve was used at encode time, so it knows how to reverse the encoding. Set this to whatever you set `source_space` to on the encoder. **Ignored for all other `hdr_mode` values.** |
## Outputs
| Output | Type | Description |
|---|---|---|
| `image` | `IMAGE` | Decoded image in the requested `target_space`. Always 4-D `(B, H, W, C)` — for 5-D video latents, the temporal dimension is collapsed into the batch dimension. |
| `debug_info` | `STRING` | Per-run diagnostic — output shape, dispatch path (`image` / `video-iter` / `video-3d`), and parameter values. |
## Valid values for `target_space` and `source_space`
Same 12 colour spaces as the encoder's `source_space`. See
[`easy_hdr_vae_encode.md`](easy_hdr_vae_encode.md#valid-values-for-source_space)
for the full list.
## Internal dispatch (what the wrapper actually does)
Pseudocode, ignoring error handling:
```
latent = samples["samples"]
is_video = latent.ndim == 5
is_3d_vae = hasattr(vae, "latent_dim") and vae.latent_dim == 3
# Sequence-aware decode dispatch
if is_video and not is_3d_vae:
img = cat([vae.decode(latent[:, :, fi, ...]) for fi in range(F)], dim=0)
else:
img = vae.decode(latent)
# Normalize 5-D output to 4-D (B*F, H, W, C) for ComfyUI IMAGE compatibility
if img.ndim == 5:
img = img.reshape(-1, H, W, C)
if target_space == "Raw":
img *= 2 ** exposure_adjust
else:
if hdr_mode == "Compress (Log)":
# Recovery pipeline for log-coded VAE output
log_space = source_space if is_log_space(source_space) else "ARRI LogC4"
knee, ceiling, threshold, strength = LOG_PROFILE_HDR_PARAMS[log_space]
img = soft_log_shoulder(img, knee=knee, ceiling=ceiling)
img = denoise_log_highlights(img, threshold=threshold, strength=strength)
img = to_linear(img, log_space) # log → linear Rec.709
else:
img = srgb_to_linear(img) # sRGB-gamma → linear Rec.709
img *= 2 ** exposure_adjust # exposure in linear
img = from_linear(img, target_space) # linear → target colour space
return (img, debug_info)
```
The full implementation lives at [`nodes_vae.py:Easy_hdr_VAE_decode.decode`](../../../../forks/radiance_koolook/versions/v2_3_3/nodes_vae.py).
## Common mistakes
- **Mismatched `hdr_mode` between encoder and decoder.** Most common is the encoder using `Compress (Log)` while the decoder uses `Clip (SDR)` (the default), or vice versa. Result: very wrong colour. The encoder's `debug_info` will say `hdr_mode=Compress (Log)`; the decoder's will say `hdr_mode=Clip (SDR)`. They must agree.
- **Forgetting to set `source_space` on the decoder when using `Compress (Log)`.** The decoder defaults `source_space = Linear`, which means "if Compress (Log) is on, use the default ARRI LogC4 log curve" — fine if the encoder also fell back to LogC4, *not fine* if you encoded with a different log curve. Set the decoder's `source_space` equal to the encoder's `source_space`.
- **Expecting a 5-D output from a 3-D-aware video VAE.** The decoder always returns a 4-D `(B*F, H, W, C)` IMAGE — that's ComfyUI convention for video sequences. If you need temporal grouping, that's downstream of this node.
## See also
- [HDR modes deep dive](hdr_modes.md)
- [Encode/decode pairing](encode_decode_pairing.md)
- [`Easy_hdr_VAE_encode`](easy_hdr_vae_encode.md)
@@ -0,0 +1,108 @@
# Easy HDR VAE Encode (Koolook)
| Aspect | Value |
|---|---|
| Display name | `Easy HDR VAE Encode (Koolook)` |
| Node ID | `Easy_hdr_VAE_encode` |
| ComfyUI category | `Koolook/VAE` |
| Source | Koolook fork of fxtdstudios/radiance v2.3.3 (slim port — drops upstream's 4K cosine-blend tile engine) |
| Source file | [`forks/radiance_koolook/versions/v2_3_3/nodes_vae.py`](../../../../forks/radiance_koolook/versions/v2_3_3/nodes_vae.py) |
| Pairs with | [`Easy_hdr_VAE_decode`](easy_hdr_vae_decode.md) |
| Cross-cutting | [HDR modes](hdr_modes.md), [Encode/decode pairing](encode_decode_pairing.md) |
## What it does
Encodes a 4-D `(B, H, W, C)` image batch *or* a 5-D `(B, F, H, W, C)`
video tensor into a VAE latent, with cinema-grade colour-space handling
and HDR awareness. Works with image VAEs (SD 1.5 / SDXL / Flux) **and**
3-D-aware video VAEs (Wan 2.2 / Hunyuan / CogVideoX / LTX) using a
single rank-aware code path.
## Inputs
### Required
| Input | Type | Description |
|---|---|---|
| `pixels` | `IMAGE` | The image batch or video tensor to encode. Accepts 3-channel (RGB) or 4-channel (RGBA). Rank can be 4-D or 5-D. |
| `vae` | `VAE` | The VAE to encode through. Same VAE must be wired to the matching decoder. |
| `source_space` | enum (12 options) | What colour space the input pixels are in. Drives a linearisation step before exposure / HDR processing. See [valid values](#valid-values-for-source_space) below. |
### Optional
| Input | Type | Default | Description |
|---|---|---|---|
| `exposure` | `FLOAT` | `0.0` | Exposure adjustment in stops, applied **in linear space** (correct semantics regardless of `source_space`). Range: `-10.0` to `+10.0`. |
| `alpha_handling` | `Preserve` / `Ignore` | `Preserve` | If `Preserve` and the input has 4 channels, the alpha channel is surfaced as the third output (a separate `IMAGE`). If `Ignore` (or input has only 3 channels), the alpha output is a zeros tensor of compatible shape. The VAE itself only ever encodes the first 3 channels regardless. |
| `hdr_mode` | enum (4 options) | `Soft Clip` | What to do with linearised values just before they hit `vae.encode()`. **Must match the decoder's `hdr_mode`** for clean roundtrips. See [HDR modes deep dive](hdr_modes.md). |
| `latent_sampling` | `sample` / `mean` / `mode` | `sample` | How to sample from the VAE posterior distribution. `sample` is ComfyUI's default (random per-run). `mean` and `mode` are deterministic — pick the posterior mean for img2img-style minimum-noise reconstruction. Falls back to `sample` if the underlying VAE class doesn't expose the raw `first_stage_model` for direct mean access. |
## Outputs
| Output | Type | Description |
|---|---|---|
| `samples` | `LATENT` | Standard ComfyUI LATENT dict `{"samples": tensor}`. For 5-D video paths through a 3-D-aware VAE, the inner tensor is also 5-D `(B, C, F, latH, latW)`. |
| `debug_info` | `STRING` | Per-run diagnostic — input shape, dispatch path (`image` / `video-iter` / `video-3d`), and parameter values. Wire to a Show Text / Display Any node to verify which dispatch branch fired. |
| `alpha` | `IMAGE` | Alpha channel surfaced separately so it can be routed *around* the VAE (which can't encode alpha) and re-composited with the decoded image downstream. Zeros tensor of compatible shape if the input had no alpha or `alpha_handling = Ignore`. |
## Valid values for `source_space`
The full set of 12 colour spaces:
| Group | Values |
|---|---|
| Common | `Linear`, `sRGB`, `Raw` |
| ACES family | `ACEScg`, `ACES 2065-1` |
| Other linear gamuts | `Rec.2020 Linear` |
| Cinema log curves | `ARRI LogC3`, `ARRI LogC4`, `Sony S-Log3`, `Panasonic V-Log`, `DaVinci Intermediate`, `RED Log3G10` |
`Raw` bypasses all colour processing — the input pixels go straight to the VAE unchanged (after exposure, which still applies as a raw multiplication). All other values trigger the appropriate to-linear-Rec.709 conversion before exposure and HDR processing.
## Internal dispatch (what the wrapper actually does)
Pseudocode, ignoring error handling:
```
if source_space == "Raw":
img *= 2 ** exposure # raw-domain multiplication
img_for_vae = img # no colour or HDR processing
else:
img = to_linear(img, source_space) # source → linear Rec.709
img *= 2 ** exposure # exposure in linear
if hdr_mode == "Compress (Log)":
log_space = source_space if is_log_space(source_space) else "ARRI LogC4"
img_for_vae = from_linear(img, log_space)
else:
img_gamma = linear_to_srgb(img)
if hdr_mode == "Clip (SDR)": img_for_vae = clamp(img_gamma, 0, 1)
elif hdr_mode == "Soft Clip": img_for_vae = soft_clip(img_gamma, knee=0.85)
elif hdr_mode == "Passthrough": img_for_vae = img_gamma # no clamp
# Sequence-aware encode dispatch
is_video = img_for_vae.ndim == 5
is_3d_vae = hasattr(vae, "latent_dim") and vae.latent_dim == 3
if is_video and not is_3d_vae:
# Iterate frames, stack on temporal dim
latent = stack([encode_with_sampling_mode(vae, img_for_vae[:, fi, ...], latent_sampling)
for fi in range(F)], dim=2)
else:
latent = encode_with_sampling_mode(vae, img_for_vae, latent_sampling)
return ({"samples": latent}, debug_info, alpha_out)
```
The full implementation lives at [`nodes_vae.py:Easy_hdr_VAE_encode.encode`](../../../../forks/radiance_koolook/versions/v2_3_3/nodes_vae.py).
## Common mistakes
- **Setting `source_space = Linear` on a typical ComfyUI sRGB-gamma input.** Most ComfyUI image-loading nodes produce sRGB-gamma data. If you set `source_space = Linear`, the wrapper will linearise data that's already linear (apply sRGB EOTF on already-EOTF'd data). Set to `sRGB` for typical ComfyUI inputs.
- **Using `Passthrough` as a "do nothing" mode for HDR roundtrip.** It actually does the worst job for HDR fidelity — the VAE was trained on `[0, 1]` data and produces noise on out-of-domain inputs. Use `Compress (Log)` for HDR or `Clip (SDR)` for SDR; reserve `Passthrough` for diagnostics.
- **Forgetting to match `hdr_mode` on the decoder.** The decoder's `hdr_mode` determines how it interprets the VAE's output; mismatched modes give wrong colour by construction. See [encode/decode pairing](encode_decode_pairing.md).
- **Wiring the `alpha` output back into a 4-channel-expecting node downstream.** Most ComfyUI IMAGE-typed nodes assume 3-channel; the `alpha` output is a separate single-channel IMAGE meant for explicit re-compositing.
## See also
- [HDR modes deep dive](hdr_modes.md)
- [Encode/decode pairing](encode_decode_pairing.md)
- [`Easy_hdr_VAE_decode`](easy_hdr_vae_decode.md)
@@ -0,0 +1,177 @@
# `Easy_hdr_VAE_encode` ↔ `Easy_hdr_VAE_decode` — pairing guide
Quick reference for which fields on the decoder must match the encoder
(and which are independent) to get a clean roundtrip.
## At a glance — pairing rules
| Encoder field | Pairs with decoder field | Rule |
|---|---|---|
| `pixels` (IMAGE in) | — | Connect your image / video stream here |
| `vae` (VAE) | `vae` (VAE) | **Must be the same VAE on both sides** — otherwise the latent is meaningless |
| `source_space` | `target_space` (decoder) | Independent. Set them to match if you want the same colour space out as in (typical roundtrip). Use a different `target_space` to convert colour space *as part of the VAE round-trip*. |
| `source_space` | `source_space` (decoder) | **Only matters when `hdr_mode = Compress (Log)`.** When in Log mode, the decoder's `source_space` tells it which log curve was used at encode time so it knows how to decompress. **Set the decoder's `source_space` equal to the encoder's `source_space`** when both are in Compress (Log) mode. Ignored otherwise. |
| `exposure` | `exposure_adjust` | Independent. Both apply in linear space, both default to 0. Use them as creative exposure controls — the encoder bumps before VAE, the decoder bumps after VAE. They don't need to be inverses unless you want the roundtrip exposure-neutral. |
| `alpha_handling` | (none) | Encode-only flag. The encoder's `alpha` IMAGE output carries the alpha **around** the VAE; the VAE itself never sees alpha. Re-composite the alpha with the decoded image downstream if you need 4-channel output. |
| `hdr_mode` | `hdr_mode` | **Must match** for clean roundtrips, especially for `Compress (Log)`. Mismatched modes will produce wrong colours by design — the decoder will misinterpret what the VAE saw. |
| `latent_sampling` | (none) | Encode-only. `sample` is non-deterministic (default). `mean` / `mode` are deterministic — best for img2img where you want minimum reconstruction noise. |
| `samples` (LATENT out) | `samples` (LATENT in) | Direct wire. KSampler / other nodes can sit between encode and decode here. |
| `debug_info` (STRING out) | `debug_info` (STRING out) | Independent diagnostics. Wire to a Show Text / Display Any node to see the actual dispatch path the wrapper took (e.g. `path=video-3d` for a 3-D-aware video VAE). |
## Worked example #1 — pure SDR roundtrip (cleanest reconstruction)
Use case: standard sRGB image input, want it back out matching the input as closely as possible.
```
ENCODER DECODER
──────────────────── ────────────────────
source_space = sRGB target_space = sRGB
exposure = 0.0 exposure_adjust = 0.0
alpha_handling = Preserve hdr_mode = Clip (SDR)
hdr_mode = Clip (SDR) source_space = (irrelevant — not in Log mode)
latent_sampling = mean
```
Why these choices:
- **`source_space`/`target_space` both sRGB** — input is gamma-coded sRGB and output should match.
- **`hdr_mode = Clip (SDR)` on both sides** — VAE was trained on sRGB-gamma in `[0, 1]`, this gives it exactly that.
- **`latent_sampling = mean`** — deterministic encode → reproducible test runs and minimum reconstruction noise.
## Worked example #2 — ACEScg HDR roundtrip (preserves highlights and wide gamut)
Use case: scene-linear ACEScg input from a 3-D render or EXR sequence with HDR highlights and wide-gamut colour, want it back out the same way.
```
ENCODER DECODER
───────────────────── ─────────────────────
source_space = ACEScg target_space = ACEScg
exposure = 0.0 exposure_adjust = 0.0
alpha_handling = Preserve hdr_mode = Compress (Log) ← matches encoder
hdr_mode = Compress (Log) source_space = ACEScg ← tells decoder which log curve was used
latent_sampling = mean
```
Why these choices:
- **`hdr_mode = Compress (Log)` on both sides** — encodes through a cinema log curve (defaults to ARRI LogC4 because `ACEScg` is not itself a log space), preserves the wide dynamic range through the VAE, and the decoder side knows to apply the soft-shoulder + log-domain highlight denoise that this pipeline relies on.
- **Decoder's `source_space = ACEScg`** — the encoder picks ARRI LogC4 as the log curve when the source is ACEScg; the decoder needs to know what was picked, and matching `source_space` to the encoder is the way to communicate it. *This is the field most commonly forgotten when people debug "Compress (Log) gave me weird colour."*
- **Both `target_space` and `source_space` set to ACEScg on the decoder** — `target_space` is "what colour space do I want the output in"; `source_space` is "what colour space did encode see, so I know which log curve was used."
## Worked example #3 — cinema log workflow (LogC4 in, sRGB out for preview)
Use case: ARRI LogC4 source footage, viewing on a standard sRGB monitor.
```
ENCODER DECODER
───────────────────── ─────────────────────
source_space = ARRI LogC4 target_space = sRGB
exposure = 0.0 exposure_adjust = 0.0
alpha_handling = Ignore hdr_mode = Compress (Log)
hdr_mode = Compress (Log) source_space = ARRI LogC4 ← tells decoder this is LogC4
latent_sampling = mean
```
Why these choices:
- **Encoder `source_space = ARRI LogC4`** — input is LogC4-coded; we want the VAE to see exactly that (the log-to-log "round-trip" path is exact for `exposure = 0` because `to_linear` followed by `from_linear` to the same log space cancels out).
- **Decoder `target_space = sRGB`** — output goes to a standard monitor; the decoder converts log-decoded linear back to sRGB-gamma for display.
- **Decoder `source_space = ARRI LogC4`** — *required* so the decoder knows which log curve to use when reversing the VAE's log-coded output.
## Worked example #4 — Wan 2.2 (or other 3-D-native video VAE)
Use case: image sequence input through a video VAE that handles temporal natively.
```
ENCODER DECODER
────────────────── ──────────────────
source_space = Linear target_space = Linear
exposure = 0.0 exposure_adjust = 0.0
alpha_handling = Ignore hdr_mode = Soft Clip
hdr_mode = Soft Clip source_space = (irrelevant — not in Log mode)
latent_sampling = sample
```
What you'll see in `debug_info`:
```
Easy_hdr_VAE_encode (Koolook v2.3.3) | input shape (1, 41, 1056, 1872, 3) |
path=video-3d | source_space=Linear | exposure=+0.00 |
hdr_mode=Soft Clip | latent_sampling=sample | alpha_handling=Ignore
```
The `path=video-3d` confirms the wrapper detected a 5-D video tensor + a 3-D-aware VAE (the `vae.latent_dim == 3` check) and passed it through directly to Wan VAE's native temporal handling.
The decoder will produce a 4-D `(B*F, H, W, C)` IMAGE batch (i.e. one IMAGE per frame, ready for `SaveImage` to write `_00001.png`, `_00002.png`, …) — that 5-D-to-4-D normalization happens unconditionally on the decode side, so SaveImage gets the layout it expects.
## "I'm getting wrong colour" — quick diagnostic checklist
Before suspecting the VAE itself:
1. **Same `vae` connected to both nodes?** Mismatched VAE = garbage latent.
2. **`hdr_mode` matches between encoder and decoder?** Different modes = wrong colour by design.
3. **If using `Compress (Log)`, does the decoder's `source_space` match the encoder's `source_space`?** This is the single most common mistake.
4. **Is your input actually in the colour space you set for `source_space`?** A common gotcha: ComfyUI nodes default to passing sRGB-gamma data; if a node upstream produces sRGB but you set `source_space = Linear`, the wrapper will linearise data that's already linear. Set `source_space = sRGB` for typical ComfyUI image inputs.
5. **`debug_info` STRING output wired to a Show Text node?** Look at the `path=…` field — if it shows `video-iter` when you expected `video-3d`, your VAE doesn't have the `latent_dim == 3` attribute (likely not a video VAE), and the wrapper is iterating frames manually. That's still correct, just slower.
6. **For HDR / wide-gamut roundtrip with visible shift:** try switching `hdr_mode` from `Passthrough` to `Compress (Log)` on both sides. `Passthrough` is the worst HDR mode for fidelity because it lets out-of-domain values into the VAE.
If after all that the colour is still wrong, that's a real bug worth filing — the wrapper internals are covered by the dispatch tests in the test suite, but real-world VAE behaviour on edge cases can surface issues. Wire `debug_info` on both nodes, capture both strings, and open an issue with the input + output and both debug strings attached.
## Reference connectivity diagram
For a typical SDR roundtrip with KSampler in between (text-to-image, img2img, etc.):
```
IMAGE input
│
│ ┌────────────┐
│ │ Load VAE │
│ └────┬───────┘
│ │
▼ ▼
┌──────────────────────────────────────┐
│ Easy_hdr_VAE_encode │
│ pixels ●───── │
│ vae ●───── │
│ source_space = sRGB │
│ exposure = 0.0 │
│ alpha_handling= Preserve │
│ hdr_mode = Clip (SDR) │ ← these three (source_space,
│ latent_sampling = mean │ hdr_mode, and on log workflows
│ samples ●───── │ the source_space) drive what
│ debug_info ●── │ the decoder needs to mirror
│ alpha ●──── │
└──────────────────────────────────────┘
│ samples
▼
┌──────────────┐
│ KSampler │ (denoise = 0 for a "passthrough" roundtrip
│ (or none) │ test — bypasses diffusion)
└──────┬───────┘
│ samples
▼
┌──────────────────────────────────────┐
│ Easy_hdr_VAE_decode │
│ samples ●───── │
│ vae ●───── │
│ target_space = sRGB │
│ exposure_adjust = 0.0 │
│ hdr_mode = Clip (SDR) │ ← matches encoder's hdr_mode
│ source_space = (n/a — not Log) │ ← only matters in Compress (Log)
│ image ●────│
│ debug_info ●────│
└──────────────────────────────────────┘
│ image
▼
┌──────────┐
│SaveImage │
└──────────┘
```
For the alpha path, route the encoder's `alpha` IMAGE around the VAE chain and re-composite with the decoded `image` downstream (e.g. via an `ImageCompositeMasked` or similar). The VAE itself only ever sees the first 3 channels; alpha bypasses it.
## See also
- [`hdr_modes.md`](hdr_modes.md) — what each HDR mode does to your pixels (per-pixel worked examples).
- [`easy_hdr_vae_encode.md`](easy_hdr_vae_encode.md) — encoder per-input reference.
- [`easy_hdr_vae_decode.md`](easy_hdr_vae_decode.md) — decoder per-input reference.
@@ -0,0 +1,89 @@
# HDR Modes — what each one does to your pixels
`hdr_mode` is a parameter on both `Easy_hdr_VAE_encode` and
`Easy_hdr_VAE_decode`. It controls **what happens to linearised pixel
values just before they enter `vae.encode()`** (and, on the decode side,
how the VAE's output is interpreted on the way back to linear).
For roundtrip workflows the encoder's `hdr_mode` and the decoder's
`hdr_mode` should **match**. See [`encode_decode_pairing.md`](encode_decode_pairing.md)
for the cross-node pairing rules.
## The four modes side by side (encode side)
| Mode | What it does to values | What VAE sees | What's lost |
|---|---|---|---|
| **Clip (SDR)** | Linear → sRGB-gamma → **hard clamp to [0, 1]** | Standard SDR-range data — what most VAEs were trained on | Anything above `1.0` (HDR highlights) → cut to white. Anything below `0` (out-of-gamut negatives from wide-gamut sources like ACEScg) → cut to black. |
| **Soft Clip** | Linear → sRGB-gamma → **tanh rolloff above 0.85**, then clamp to [0, 1] | SDR-range data with smoothly curved highlights instead of a hard cliff | Highlights above ~0.85 progressively compressed; very bright pixels still end up near 1.0 but with no harsh clipped-white line. Negatives still clamped to 0. |
| **Compress (Log)** | Linear → **cinema log curve** (ARRI LogC4 by default, or matches `source_space` if `source_space` is itself a log space). No clamp. | Log-coded HDR data — squashes a wide dynamic range into [0, ~1.08] | Almost nothing — the log curve is designed for this. Caveat: the VAE wasn't trained on log-coded data, so reconstruction has its own characteristic noise. The decoder addresses this with per-profile soft-shoulder + log-domain highlight denoise — but it only kicks in when the decoder also has `hdr_mode = Compress (Log)`. |
| **Passthrough** | Linear → sRGB-gamma. **No clamp at all.** Negatives stay negative; values above 1.0 stay above 1.0. | Whatever the input gives it — including out-of-domain values | VAE behaviour on values it never saw in training (`>1` or `<0`) is undefined. It typically mashes them — clips highlights anyway, makes negatives weird. Output is often *worse* than `Clip (SDR)` in practice. |
## Concrete per-pixel examples
### SDR midtone — linear input value `0.4`
| Mode | sRGB-gamma stage | Final to VAE |
|---|---|---|
| Clip (SDR) | `0.665` | `0.665` (in [0, 1]) |
| Soft Clip | `0.665` | `0.665` (below 0.85 knee, passthrough) |
| Compress (Log) | (skips sRGB step entirely) | `~0.55` LogC4-coded |
| Passthrough | `0.665` | `0.665` |
All four modes preserve a normal SDR midtone identically — they only diverge at the extremes.
### Super-white HDR highlight — linear input value `5.0` (e.g. sun, explosion, VDB fire)
| Mode | sRGB-gamma stage | Final to VAE |
|---|---|---|
| Clip (SDR) | `1.94` | `1.0` ← **lost super-white** |
| Soft Clip | `1.94` | `~0.99` ← **lost most super-white**, but smoothly |
| Compress (Log) | (skips sRGB) | `~0.79` LogC4-coded ← **preserved** |
| Passthrough | `1.94` | `1.94` ← VAE will likely mash this anyway |
`Compress (Log)` is the only mode that actually preserves HDR highlights through the VAE.
### Out-of-gamut negative — linear input value `-0.05` (e.g. saturated cyan from ACEScg → Rec.709)
| Mode | Behaviour |
|---|---|
| Clip (SDR) | Clamped to `0.0` ← lost |
| Soft Clip | Clamped to `0.0` ← lost |
| Compress (Log) | Floored to `0.0` (no valid signal below 0 in log space) ← lost |
| Passthrough | Stays at `-0.05` ← VAE mashes it; usually worse than just clamping |
Wide-gamut colour preservation isn't really a job the VAE can do; if you need it, keep your wide-gamut data outside the VAE chain.
## When to use which
| Mode | Use when |
|---|---|
| **Clip (SDR)** | You have a normal SDR image / sequence (values fit in [0, 1] after colour-space conversion) and you want the cleanest VAE reconstruction in the SDR range. The VAE is in its native domain, so this gives the highest fidelity for non-HDR content. |
| **Soft Clip** | You have bright/HDR-ish content but you don't want to deal with log curves on both sides. The smooth highlight rolloff is more pleasant than hard clipping for things like rim-lit subjects, sky highlights, etc. |
| **Compress (Log)** | You're doing actual HDR / cinema / film work — ACES pipelines, ARRI / Sony / RED / Panasonic source material, scene-linear EXR sequences, etc. **Pair with the same mode on decode** plus `source_space` set to whichever log curve you encoded with. Best dynamic-range fidelity, at the cost of needing matched encode/decode settings. |
| **Passthrough** | Almost never the right answer for production. It's an "I know what I'm doing, give me the raw values" escape hatch. For roundtrip fidelity it's usually worse than `Clip (SDR)` because the VAE has nothing trained for out-of-domain inputs and produces characteristic noise. Useful for diagnosing what the VAE is actually receiving. |
## Trade-off matrix
| | SDR fidelity | HDR preservation | Highlight smoothness | Roundtrip cleanliness | Requires matched decode mode |
|---|---|---|---|---|---|
| Clip (SDR) | ★★★★★ | ✗ | ✗ (hard cut) | ★★★★★ (in-range) | No — same path on decode |
| Soft Clip | ★★★★ | ✗ | ★★★★ | ★★★★ | No — same path on decode |
| Compress (Log) | ★★★ | ★★★★★ | ★★★ (per-profile) | ★★★★ (with matched decode) | **Yes** — decode also Compress (Log) + matching `source_space` |
| Passthrough | ★★ | ★ | ✗ | ★ (often worse than Clip) | No — same path on decode |
## Behind the scenes — where each mode is implemented
- **Encode side:** [`forks/radiance_koolook/versions/v2_3_3/nodes_vae.py`](../../../../forks/radiance_koolook/versions/v2_3_3/nodes_vae.py),
search for `if hdr_mode == "Compress (Log)"` to see the dispatch.
- **Log curves + soft-shoulder + log-domain denoise:**
[`forks/radiance_koolook/versions/v2_3_3/color_helpers.py`](../../../../forks/radiance_koolook/versions/v2_3_3/color_helpers.py).
- **Per-profile HDR parameters** (`LOG_PROFILE_HDR_PARAMS`): tuned per
curve based on each curve's slope at code 1.0. RED Log3G10 (the
steepest, slope > 1100) gets the most conservative knee and most
aggressive denoise; Sony S-Log3 / ARRI LogC4 (moderate-steep) get the
loosest. See the docstring in `color_helpers.py` for the full table.
- **Why we don't just hard-clamp Compress (Log)**: VAE reconstruction
noise is roughly uniform in log-coded space, but log→linear amplifies
that noise exponentially in the highlights. A hard clamp at 1.0 would
destroy legitimate super-white signal. The soft-shoulder + log-space
denoise approach is the same principle DaVinci Resolve and Nuke use.
+611
View File
@@ -0,0 +1,611 @@
{
"id": "2b0a8c0c-abe2-423b-99a6-ac7940f66066",
"revision": 0,
"last_node_id": 1266,
"last_link_id": 1981,
"nodes": [
{
"id": 992,
"type": "PreviewImage",
"pos": [
-7932.9859136470905,
9673.132708942472
],
"size": [
259.8657531738281,
246.0000000000001
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1583
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.64",
"Node name for S&R": "PreviewImage",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.2.2",
"input_ue_unconnectable": {}
}
},
"widgets_values": []
},
{
"id": 993,
"type": "LoadEXR",
"pos": [
-7936.962735728316,
9406.273945790455
],
"size": [
270,
194
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "filepath",
"type": "STRING",
"widget": {
"name": "filepath"
},
"link": 1603
}
],
"outputs": [
{
"name": "RGB",
"type": "IMAGE",
"links": [
1583
]
},
{
"name": "alpha",
"type": "MASK",
"links": null
},
{
"name": "batch_size",
"type": "INT",
"links": null
}
],
"title": "ONE frame",
"properties": {
"cnr_id": "ComfyUI-HQ-Image-Save",
"ver": "52b4579a195727729bf768032737a67220512128",
"Node name for S&R": "LoadEXR",
"ue_properties": {
"widget_ue_connectable": {
"filepath": true
},
"version": "7.2.2",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"path to directory or .exr file",
"linear",
1,
0,
1
]
},
{
"id": 997,
"type": "Label (rgthree)",
"pos": [
-7873.6231611078665,
8978.78128013376
],
"size": [
352.9296875,
50
],
"flags": {
"allow_interaction": true
},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "SOURCE depth",
"properties": {
"fontSize": 50,
"fontFamily": "Arial",
"fontColor": "#ffffff",
"textAlign": "left",
"backgroundColor": "transparent",
"padding": 0,
"borderRadius": 0,
"angle": 0,
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.2.2",
"input_ue_unconnectable": {}
}
},
"color": "#fff0",
"bgcolor": "#fff0"
},
{
"id": 1006,
"type": "LoadEXR",
"pos": [
-7606.047936758987,
9397.38306603451
],
"size": [
285.9300537109375,
349.7812194824219
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "filepath",
"type": "STRING",
"widget": {
"name": "filepath"
},
"link": 1602
}
],
"outputs": [
{
"name": "RGB",
"type": "IMAGE",
"links": [
1587,
1635
]
},
{
"name": "alpha",
"type": "MASK",
"links": null
},
{
"name": "batch_size",
"type": "INT",
"links": null
}
],
"properties": {
"cnr_id": "ComfyUI-HQ-Image-Save",
"ver": "52b4579a195727729bf768032737a67220512128",
"Node name for S&R": "LoadEXR",
"ue_properties": {
"version": "7.1",
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"path to directory or .exr file",
"linear",
0,
0,
1
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 1018,
"type": "Text Multiline",
"pos": [
-7933.365538801957,
9209.448315354415
],
"size": [
593.5753886350403,
123.31427023937988
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [
1602,
1603,
1604
]
}
],
"properties": {
"cnr_id": "was-node-suite-comfyui",
"ver": "1.0.2",
"Node name for S&R": "Text Multiline",
"ue_properties": {
"widget_ue_connectable": {
"text": true
},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"path/to/sequence_b"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 1036,
"type": "PreviewImage",
"pos": [
-7195.925843067886,
8728.97470868871
],
"size": [
728.4322134178647,
394.6436280241378
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1635
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.8.2",
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 1002,
"type": "EasyAIPipeline",
"pos": [
-6976.199736846407,
9648.149788665098
],
"size": [
421.5911559488459,
642.1453522689535
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "base_directory_path",
"type": "STRING",
"widget": {
"name": "base_directory_path"
},
"link": 1604
}
],
"outputs": [
{
"name": "WRITE_file_path",
"type": "STRING",
"links": [
1591
]
},
{
"name": "output_name",
"type": "STRING",
"links": null
},
{
"name": "version_string",
"type": "STRING",
"links": null
},
{
"name": "output_directory",
"type": "STRING",
"links": null
},
{
"name": "shot_duration",
"type": "INT",
"links": null
},
{
"name": "seed_value",
"type": "INT",
"links": null
},
{
"name": "shot_name",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "koolook",
"ver": "f24db5ce965cd22ccad7a4af850dd7e212d6a791",
"Node name for S&R": "EasyAIPipeline",
"aux_id": "malkuthro/Koolook_Wan_22",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": [
81,
453453453,
"Place your base folder path in the FIELD below",
"path/to/source_video",
".%04d.exr",
"Source-depth",
"v2-vikitti",
1,
true,
true,
"path/to/source_depth",
null,
null
],
"color": "#2a363b",
"bgcolor": "#3f5159"
},
{
"id": 998,
"type": "DepthAnything_V2",
"pos": [
-6827.780390691215,
9375.615594844607
],
"size": [
214.82899913241127,
53.8174710455969
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "da_model",
"type": "DAMODEL",
"link": 1586
},
{
"name": "images",
"type": "IMAGE",
"link": 1587
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
1590
]
}
],
"properties": {
"cnr_id": "comfyui-depthanythingv2",
"ver": "d505cbca99803fc63327b8305618a23e59a18b42",
"Node name for S&R": "DepthAnything_V2",
"ue_properties": {
"version": "7.1",
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
}
},
"widgets_values": [],
"color": "#223",
"bgcolor": "#335"
},
{
"id": 1001,
"type": "DownloadAndLoadDepthAnythingV2Model",
"pos": [
-7080.8878988856995,
9213.36792146106
],
"size": [
451.25884409922674,
87.58390788971155
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "da_v2_model",
"type": "DAMODEL",
"links": [
1586
]
}
],
"properties": {
"cnr_id": "comfyui-depthanythingv2",
"ver": "d505cbca99803fc63327b8305618a23e59a18b42",
"Node name for S&R": "DownloadAndLoadDepthAnythingV2Model",
"ue_properties": {
"version": "7.1",
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"depth_anything_v2_metric_vkitti_vitl_fp32.safetensors"
],
"color": "#223",
"bgcolor": "#335"
},
{
"id": 1000,
"type": "SaveEXRFrames",
"pos": [
-6511.004406427462,
9375.22296859613
],
"size": [
338.36407470703125,
615.3633422851562
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1590
},
{
"name": "filepath",
"type": "STRING",
"widget": {
"name": "filepath"
},
"link": 1591
}
],
"outputs": [],
"properties": {
"cnr_id": "ComfyUI-HQ-Image-Save",
"ver": "52b4579a195727729bf768032737a67220512128",
"Node name for S&R": "SaveEXRFrames",
"ue_properties": {
"version": "7.1",
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"path/to/frame%04d.exr",
"linear",
1001,
false,
"ui"
],
"color": "#323",
"bgcolor": "#535"
}
],
"links": [
[
1583,
993,
0,
992,
0,
"IMAGE"
],
[
1586,
1001,
0,
998,
0,
"DAMODEL"
],
[
1587,
1006,
0,
998,
1,
"IMAGE"
],
[
1590,
998,
0,
1000,
0,
"IMAGE"
],
[
1591,
1002,
0,
1000,
1,
"STRING"
],
[
1602,
1018,
0,
1006,
0,
"STRING"
],
[
1603,
1018,
0,
993,
0,
"STRING"
],
[
1604,
1018,
0,
1002,
0,
"STRING"
],
[
1635,
1006,
0,
1036,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.505447028499295,
"offset": [
8829.571508342653,
-7999.799463578146
]
},
"frontendVersion": "1.35.9",
"workflowRendererVersion": "LG",
"node_versions": {
"ComfyUI-WanVideoWrapper": "5a2383621a05825d0d0437781afcb8552d9590fd",
"comfy-core": "0.3.26",
"ComfyUI-KJNodes": "a5bd3c86c8ed6b83c55c2d0e7a59515b15a0137f",
"ComfyUI-VideoHelperSuite": "0a75c7958fe320efcb052f1d9f8451fd20c730a8"
},
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"ue_links": [],
"links_added_by_ue": []
},
"version": 0.4
}
+898
View File
@@ -0,0 +1,898 @@
{
"id": "b24a0624-6eeb-4a53-ae01-96976b90118b",
"revision": 0,
"last_node_id": 37,
"last_link_id": 34,
"nodes": [
{
"id": 1,
"type": "SaveEXRFrames",
"pos": [
3318.6065813153327,
-56.874361817991016
],
"size": [
338.36407470703125,
615.3633422851562
],
"flags": {},
"order": 13,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
},
{
"name": "filepath",
"type": "STRING",
"widget": {
"name": "filepath"
},
"link": 2
}
],
"outputs": [],
"properties": {
"cnr_id": "ComfyUI-HQ-Image-Save",
"ver": "52b4579a195727729bf768032737a67220512128",
"Node name for S&R": "SaveEXRFrames",
"ue_properties": {
"version": "7.1",
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"path/to/frame%04d.exr",
"linear",
1001,
false,
"ui"
],
"color": "#323",
"bgcolor": "#535"
},
{
"id": 17,
"type": "LoadEXR",
"pos": [
3691.5625811634764,
79.92192775119034
],
"size": [
270,
194
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "filepath",
"type": "STRING",
"widget": {
"name": "filepath"
},
"link": 14
}
],
"outputs": [
{
"name": "RGB",
"type": "IMAGE",
"links": [
15
]
},
{
"name": "alpha",
"type": "MASK",
"links": null
},
{
"name": "batch_size",
"type": "INT",
"links": null
}
],
"properties": {
"cnr_id": "ComfyUI-HQ-Image-Save",
"ver": "52b4579a195727729bf768032737a67220512128",
"Node name for S&R": "LoadEXR",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": [
"path to directory or .exr file",
"linear",
0,
0,
1
]
},
{
"id": 6,
"type": "GetImageSizeAndCount",
"pos": [
1481.0290842611375,
30.722427113459077
],
"size": [
196.2994140625,
86
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 5
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": null
},
{
"label": "1920 width",
"name": "width",
"type": "INT",
"links": [
7,
22
]
},
{
"label": "1080 height",
"name": "height",
"type": "INT",
"links": [
4,
23
]
},
{
"label": "81 count",
"name": "count",
"type": "INT",
"links": null
}
],
"properties": {
"cnr_id": "comfyui-kjnodes",
"ver": "1.1.9",
"Node name for S&R": "GetImageSizeAndCount",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": []
},
{
"id": 9,
"type": "easy showAnything",
"pos": [
1690.9158486091035,
229.5702849687141
],
"size": [
210,
88
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "anything",
"shape": 7,
"type": "*",
"link": 7
}
],
"outputs": [
{
"name": "output",
"type": "*",
"links": null
}
],
"properties": {
"cnr_id": "comfyui-easy-use",
"ver": "1.3.4",
"Node name for S&R": "easy showAnything",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": [
"1920"
]
},
{
"id": 5,
"type": "easy showAnything",
"pos": [
1447.2646213349187,
229.38469471423394
],
"size": [
210,
88
],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "anything",
"shape": 7,
"type": "*",
"link": 4
}
],
"outputs": [
{
"name": "output",
"type": "*",
"links": null
}
],
"properties": {
"cnr_id": "comfyui-easy-use",
"ver": "1.3.4",
"Node name for S&R": "easy showAnything",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": [
"1080"
]
},
{
"id": 12,
"type": "Reroute",
"pos": [
1126.8771433051406,
-245.4533858492232
],
"size": [
75,
26
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 10
}
],
"outputs": [
{
"name": "",
"type": "IMAGE",
"links": [
8,
24
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false,
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.2.2",
"input_ue_unconnectable": {}
}
}
},
{
"id": 10,
"type": "ImageScaleBy",
"pos": [
1409.4331609425535,
-113.371733507784
],
"size": [
270,
82
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
5
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.75",
"Node name for S&R": "ImageScaleBy",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": [
"area",
1.5
]
},
{
"id": 18,
"type": "PreviewImage",
"pos": [
3701.0363198667783,
327.7900569355131
],
"size": [
1201.9980600596919,
669.1034801466919
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 15
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.76",
"Node name for S&R": "PreviewImage",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": []
},
{
"id": 15,
"type": "Text Multiline",
"pos": [
235.8074140117446,
-221.48664554792862
],
"size": [
400,
200
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [
19
]
}
],
"properties": {
"cnr_id": "was-node-suite-comfyui",
"ver": "1.0.2",
"Node name for S&R": "Text Multiline",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": [
"path/to/sequence_a"
]
},
{
"id": 23,
"type": "LoadEXR",
"pos": [
719.5796614098845,
-285.81230652231585
],
"size": [
270,
194
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "filepath",
"type": "STRING",
"widget": {
"name": "filepath"
},
"link": 19
}
],
"outputs": [
{
"name": "RGB",
"type": "IMAGE",
"links": [
10,
13
]
},
{
"name": "alpha",
"type": "MASK",
"links": null
},
{
"name": "batch_size",
"type": "INT",
"links": null
}
],
"properties": {
"cnr_id": "ComfyUI-HQ-Image-Save",
"ver": "52b4579a195727729bf768032737a67220512128",
"Node name for S&R": "LoadEXR",
"ue_properties": {
"widget_ue_connectable": {
"filepath": true,
"tonemap": true,
"image_load_cap": true,
"skip_first_images": true,
"select_every_nth": true
},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"path to directory or .exr file",
"linear",
81,
0,
1
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 20,
"type": "EasyAIPipeline",
"pos": [
2733.0110767703527,
35.23518934271729
],
"size": [
546.119671913797,
682.1913422648058
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "WRITE_file_path",
"type": "STRING",
"links": [
2
]
},
{
"name": "output_name",
"type": "STRING",
"links": null
},
{
"name": "version_string",
"type": "STRING",
"links": null
},
{
"name": "output_directory",
"type": "STRING",
"links": [
14
]
},
{
"name": "shot_duration",
"type": "INT",
"links": null
},
{
"name": "seed_value",
"type": "INT",
"links": null
},
{
"name": "shot_name",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "koolook",
"ver": "f24db5ce965cd22ccad7a4af850dd7e212d6a791",
"Node name for S&R": "EasyAIPipeline",
"aux_id": "malkuthro/Koolook_Wan_22",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": [
81,
453453453,
"Place your base folder path in the FIELD below",
"path/to/sequence_a",
".%04d.exr",
"UPres-0.3",
"",
7,
false,
false,
"path/to/sequence_a/UPres-0.3/v003/UPres-0.3__v003.%04d.exr",
null,
null
],
"color": "#233",
"bgcolor": "#355"
},
{
"id": 25,
"type": "9b637afd-eb99-4941-a288-438642a560c0",
"pos": [
2066.8846360099283,
-242.67261203922772
],
"size": [
480.3395560479155,
494.196318307497
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 24
},
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": 22
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": 23
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
34
]
}
],
"properties": {
"proxyWidgets": [
[
"-1",
"width"
],
[
"-1",
"height"
],
[
"6",
"text"
],
[
"3",
"seed"
],
[
"3",
"denoise"
]
],
"cnr_id": "comfy-core",
"ver": "0.3.67",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.2.2",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
1920,
1080
]
},
{
"id": 16,
"type": "PreviewImage",
"pos": [
401.05847433521893,
-685.6696159345132
],
"size": [
573.048406068714,
319.33091697515795
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 13
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.76",
"Node name for S&R": "PreviewImage",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": []
},
{
"id": 4,
"type": "GetImageSizeAndCount",
"pos": [
2729.415657149835,
-242.45581167978264
],
"size": [
196.2994140625,
86
],
"flags": {},
"order": 12,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 34
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
1
]
},
{
"label": "width",
"name": "width",
"type": "INT",
"links": null
},
{
"label": "height",
"name": "height",
"type": "INT",
"links": null
},
{
"label": "count",
"name": "count",
"type": "INT",
"links": null
}
],
"properties": {
"cnr_id": "comfyui-kjnodes",
"ver": "1.1.9",
"Node name for S&R": "GetImageSizeAndCount",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.2.2"
}
},
"widgets_values": []
}
],
"links": [
[
1,
4,
0,
1,
0,
"IMAGE"
],
[
2,
20,
0,
1,
1,
"STRING"
],
[
4,
6,
2,
5,
0,
"*"
],
[
5,
10,
0,
6,
0,
"IMAGE"
],
[
7,
6,
1,
9,
0,
"*"
],
[
8,
12,
0,
10,
0,
"IMAGE"
],
[
10,
23,
0,
12,
0,
"*"
],
[
13,
23,
0,
16,
0,
"IMAGE"
],
[
14,
20,
3,
17,
0,
"STRING"
],
[
15,
17,
0,
18,
0,
"IMAGE"
],
[
19,
15,
0,
23,
0,
"STRING"
],
[
22,
6,
1,
25,
1,
"INT"
],
[
23,
6,
2,
25,
2,
"INT"
],
[
24,
12,
0,
25,
0,
"IMAGE"
],
[
34,
25,
0,
4,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.22379213535069217,
"offset": [
2086.065795358603,
2357.105094980027
]
},
"workflowRendererVersion": "LG",
"frontendVersion": "1.35.9",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
+48
View File
@@ -0,0 +1,48 @@
# Workflow regression fixtures
Pinned ComfyUI workflow JSONs that exercise Koolook nodes. Used by the
pre-flight script (TBD — see `tools/preflight_release.py`) to verify
that every Koolook node ID referenced in any of these workflows still
exists in the current `NODE_CLASS_MAPPINGS` (or has a back-compat alias).
The pre-flight test only checks **Koolook-pack node IDs**. Other-pack
node IDs (DepthAnything, rgthree, etc.) are silently ignored — those
packs may or may not be installed on a given machine, and tracking
their stability isn't our job.
## How to add a new fixture
1. In ComfyUI, build a workflow that exercises one or more Koolook nodes.
2. Click **Save** to export the workflow JSON.
3. Name the file descriptively: `<feature>-<short-tag>-v<NN>.json`
(e.g. `vae-roundtrip-srgb-v01.json`).
4. Drop it in this folder.
5. Add a one-line entry to the inventory table below.
6. **Sanity-check before committing:**
- No Windows-style absolute paths (`C:\…`, `D:\…`).
- No huge base64 blobs (embedded preview thumbnails) — strip them in
ComfyUI's save dialog if present.
- No tokens / credentials / personal info in any node parameter.
- File size under ~50 KB ideally; certainly under 500 KB.
The pre-flight script picks the file up automatically — no need to
register it anywhere else.
## Fixture inventory
| File | Koolook nodes referenced | Notes |
|---|---|---|
| [`Depth-Anything3-v01.json`](Depth-Anything3-v01.json) | `EasyAIPipeline` | Depth-Anything v3 workflow with Koolook pipeline node + EXR I/O |
| [`Hires-FIX-wanT2V-v01.json`](Hires-FIX-wanT2V-v01.json) | `EasyAIPipeline` | Hires-fix variant for Wan T2V workflows |
## What the pre-flight script does (for reference)
For each `*.json` in this folder:
1. Parse the JSON.
2. Walk the `nodes` array (ComfyUI canvas format) — for each node, read its `type` field.
3. Filter: keep only types that look like Koolook node IDs (heuristics: starts with `Easy`, `Koolook`, contains `_koolook_`, etc. — full pattern lives in the script).
4. For each Koolook ID, verify it appears in the current `NODE_CLASS_MAPPINGS` (computed by importing the package).
5. **Fail** the pre-flight if any referenced ID is missing — that's a breaking change to a saved workflow that needs either a back-compat alias or an explicit major version bump.
If the JSON is in API format (different shape, often produced by `ComfyUI` API export), the script also handles that: walk the top-level dict where each key is a node instance and each value has `class_type`.
+297
View File
@@ -0,0 +1,297 @@
"""
Stub-VAE dispatch test for the v2_3_3 wrapper, extracted from
`preflight_release.py` to keep the main script under the project's
~400-line file-size guideline.
This module:
- Builds a `FakeTensor` (`FT`) class that mimics just enough of
`torch.Tensor` to drive the wrapper's dispatch logic (no real math).
- Builds a `torch` stub module + `torch.nn` / `torch.nn.functional`
submodule stubs.
- Loads the v2_3_3 wrapper code (`color_helpers.py` + `nodes_vae.py`)
via `importlib` against the stub torch.
- Runs five test cases through `Easy_hdr_VAE_encode` / `_decode`,
asserting that the per-case dispatch path appears in `debug_info`.
- Cleans up `sys.modules` in a `finally` block — including the
`_pf_pkg*` entries — so a partial-load failure can't leak the stub
torch into anything else running in the same process.
Public surface: `run(verbose: bool, repo_root: Path) -> tuple[bool, list[str]]`.
The caller (in `preflight_release.py`) is responsible for the `[2/4] …`
header and the PASS/FAIL line; this module just executes and reports.
"""
from __future__ import annotations
import importlib.machinery
import importlib.util
import sys
import types
from pathlib import Path
# ───────────────────────────────────────────────────────────────────────
# FakeTensor stub
# ───────────────────────────────────────────────────────────────────────
class FakeTensor:
"""Tensor-shaped duck type that records its shape and supports the
handful of operations the v2_3_3 wrapper does at dispatch time."""
def __init__(self, shape):
self.shape = tuple(shape)
self.dtype = "float32"
self.device = "cpu"
@property
def ndim(self):
return len(self.shape)
@property
def T(self):
s = list(self.shape)
if len(s) >= 2:
s[-1], s[-2] = s[-2], s[-1]
return FakeTensor(s)
def clone(self):
return FakeTensor(self.shape)
def float(self):
return self
def __getitem__(self, key):
# Slicing patterns the wrapper actually uses:
# pixels[:, fi, ...] → drop dim 1, keep rest
# latent[:, :, fi, ...] → drop dim 2, keep rest
# img[..., :3] → keep leading dims, last dim = 3
if isinstance(key, tuple) and len(key) == 3 and key[2] is Ellipsis:
return FakeTensor((self.shape[0],) + self.shape[2:])
if isinstance(key, tuple) and len(key) == 4 and key[3] is Ellipsis:
return FakeTensor((self.shape[0], self.shape[1]) + self.shape[3:])
if isinstance(key, tuple) and key[0] is Ellipsis:
return FakeTensor(self.shape[:-1] + (3,))
return self
def reshape(self, *a):
return FakeTensor(a)
def to(self, **k):
return self
def __mul__(self, o): return self
def __rmul__(self, o): return self
def __pow__(self, o): return self
def __sub__(self, o): return self
def clamp(self, *a, **k): return self
def __matmul__(self, o):
return FakeTensor(self.shape[:-1] + (o.shape[-1],))
# ───────────────────────────────────────────────────────────────────────
# torch stub
# ───────────────────────────────────────────────────────────────────────
def _build_torch_stub() -> tuple[types.ModuleType, types.ModuleType, types.ModuleType]:
"""Returns the (`torch`, `torch.nn`, `torch.nn.functional`) stub triple."""
torch_stub = types.ModuleType("torch")
torch_stub.Tensor = FakeTensor
torch_stub.stack = lambda ts, dim=0: (
lambda s: (s.insert(dim, len(ts)), FakeTensor(s))[1]
)(list(ts[0].shape))
torch_stub.cat = lambda ts, dim=0: (
lambda s: (s.__setitem__(dim, sum(x.shape[dim] for x in ts)), FakeTensor(s))[1]
)(list(ts[0].shape))
torch_stub.where = lambda c, a, b: a
torch_stub.sign = lambda x: x
torch_stub.abs = lambda x: x
torch_stub.pow = lambda x, p: x
torch_stub.tanh = lambda x: x
torch_stub.clamp = lambda x, *a, **k: x
torch_stub.tensor = lambda *a, **k: FakeTensor((3, 3))
torch_stub.float32 = "float32"
torch_stub.zeros = lambda shape, dtype=None, device=None: FakeTensor(shape)
nn_F = types.ModuleType("torch.nn.functional")
nn_F.pad = lambda x, *a, **k: x
nn_F.avg_pool2d = lambda x, *a, **k: x
nn_mod = types.ModuleType("torch.nn")
nn_mod.functional = nn_F
torch_stub.nn = nn_mod
return torch_stub, nn_mod, nn_F
# ───────────────────────────────────────────────────────────────────────
# Stub VAE classes
# ───────────────────────────────────────────────────────────────────────
class Fake2DVAE:
"""A vanilla 2D image VAE — no `latent_dim` attribute, so the wrapper
treats it as the standard SD/SDXL/Flux case."""
def encode(self, pixels):
return FakeTensor(
(1, 16, pixels.shape[1] // 8, pixels.shape[2] // 8)
)
def decode(self, lat):
return FakeTensor(
(lat.shape[0], lat.shape[2] * 8, lat.shape[3] * 8, 3)
)
class Fake3DVAE:
"""A 3D-aware video VAE — exposes `latent_dim = 3` so the wrapper
detects it and short-circuits the per-frame iteration on encode."""
latent_dim = 3
def encode(self, pixels):
if pixels.ndim == 5:
return FakeTensor((
1, 16,
pixels.shape[1] // 4,
pixels.shape[2] // 8,
pixels.shape[3] // 8,
))
return FakeTensor((1, 16, pixels.shape[1] // 8, pixels.shape[2] // 8))
def decode(self, lat):
if lat.ndim == 5:
return FakeTensor((
1, lat.shape[2] * 4,
lat.shape[3] * 8, lat.shape[4] * 8, 3,
))
return FakeTensor((lat.shape[0], lat.shape[2] * 8, lat.shape[3] * 8, 3))
# ───────────────────────────────────────────────────────────────────────
# Public entry point
# ───────────────────────────────────────────────────────────────────────
def run(verbose: bool, repo_root: Path) -> tuple[bool, list[str]]:
"""Run the five dispatch test cases and report.
Returns:
(success, failures)
- `success`: True if every case passed.
- `failures`: list of "<case_name>: <error>" strings (empty on success).
On success, a per-case "OK" line is printed when `verbose=True`.
Header / PASS-FAIL formatting is the caller's responsibility — this
module is the mechanical part.
"""
torch_stub, nn_mod, nn_F = _build_torch_stub()
pkg_path = repo_root / "forks" / "radiance_koolook" / "versions" / "v2_3_3"
# Track every sys.modules key we add so the finally block can pop them
# cleanly even if exec_module / constructors raise.
injected_modules: list[str] = []
try:
sys.modules["torch"] = torch_stub
injected_modules.append("torch")
sys.modules["torch.nn"] = nn_mod
injected_modules.append("torch.nn")
sys.modules["torch.nn.functional"] = nn_F
injected_modules.append("torch.nn.functional")
pkg_spec = importlib.machinery.ModuleSpec(
name="_pf_pkg", loader=None, is_package=True
)
pkg_spec.submodule_search_locations = [str(pkg_path)]
pkg = importlib.util.module_from_spec(pkg_spec)
sys.modules["_pf_pkg"] = pkg
injected_modules.append("_pf_pkg")
ch_spec = importlib.util.spec_from_file_location(
"_pf_pkg.color_helpers", str(pkg_path / "color_helpers.py")
)
ch_mod = importlib.util.module_from_spec(ch_spec)
sys.modules["_pf_pkg.color_helpers"] = ch_mod
injected_modules.append("_pf_pkg.color_helpers")
ch_spec.loader.exec_module(ch_mod)
nv_spec = importlib.util.spec_from_file_location(
"_pf_pkg.nodes_vae", str(pkg_path / "nodes_vae.py")
)
nv_mod = importlib.util.module_from_spec(nv_spec)
sys.modules["_pf_pkg.nodes_vae"] = nv_mod
injected_modules.append("_pf_pkg.nodes_vae")
nv_spec.loader.exec_module(nv_mod)
return _run_cases(nv_mod, verbose)
finally:
for mod in injected_modules:
sys.modules.pop(mod, None)
def _run_cases(nv_mod, verbose: bool) -> tuple[bool, list[str]]:
"""The actual five dispatch test cases. Receives the loaded
`nodes_vae` module — which now sees our stub torch."""
enc = nv_mod.Easy_hdr_VAE_encode()
dec = nv_mod.Easy_hdr_VAE_decode()
common_enc = dict(
source_space="Raw", hdr_mode="Passthrough",
exposure=0.0, alpha_handling="Preserve", latent_sampling="sample",
)
common_dec = dict(
target_space="Raw", exposure_adjust=0.0,
hdr_mode="Clip (SDR)", source_space="Linear",
)
def case_4d_image():
lat, dbg, _ = enc.encode(FakeTensor((2, 64, 64, 3)), Fake2DVAE(), **common_enc)
assert lat["samples"].ndim == 4 and "path=image" in dbg
def case_5d_2dvae():
lat, dbg, _ = enc.encode(FakeTensor((1, 8, 64, 64, 3)), Fake2DVAE(), **common_enc)
assert (
lat["samples"].ndim == 5
and lat["samples"].shape[2] == 8
and "path=video-iter" in dbg
)
def case_5d_3dvae():
lat, dbg, _ = enc.encode(FakeTensor((1, 8, 64, 64, 3)), Fake3DVAE(), **common_enc)
assert "path=video-3d" in dbg
def case_5d_lat_decode():
img, dbg = dec.decode(
{"samples": FakeTensor((1, 16, 8, 8, 8))}, Fake2DVAE(), **common_dec
)
assert img.ndim == 4 and img.shape[0] == 8 and "path=video-iter" in dbg
def case_4d_lat_decode():
img, dbg = dec.decode(
{"samples": FakeTensor((2, 16, 8, 8))}, Fake2DVAE(), **common_dec
)
assert "path=image" in dbg
cases = [
("4D image, 2D-VAE", case_4d_image),
("5D + 2D-VAE iter", case_5d_2dvae),
("5D + 3D-VAE pass", case_5d_3dvae),
("5D latent decode iter", case_5d_lat_decode),
("4D latent decode pass", case_4d_lat_decode),
]
failures: list[str] = []
for name, fn in cases:
try:
fn()
if verbose:
# Caller can repaint this with color if it wants —
# here we just emit a plain line so the module stays
# color-helper-free.
print(f" OK {name}")
except Exception as exc:
failures.append(f"{name}: {type(exc).__name__}: {exc}")
return (not failures), failures
+508
View File
@@ -0,0 +1,508 @@
#!/usr/bin/env python3
"""
Pre-flight release validation for ComfyUI-Koolook.
Runs four checks and exits non-zero on any failure:
1. static AST extraction of NODE_CLASS_MAPPINGS keys; verifies all
*.py files parse and that we can produce a definitive list
of registered node IDs without importing torch.
2. dispatch Stub-VAE round through the v2_3_3 wrapper; verifies all
five rank/VAE dispatch branches still wire correctly.
3. manager-meta Fetches ComfyUI-Manager's extension-node-map.json upstream
and diffs the entry for our repo against the AST-extracted
NODE_CLASS_MAPPINGS. Catches phantom-nodes / missing-nodes
drift (the issue #44 class of bug).
4. workflows Walks tests/workflows/*.json, extracts node IDs that look
like Koolook-pack IDs, verifies each one is currently
registered (i.e. wouldn't break a saved workflow).
Usage:
python tools/preflight_release.py # run all checks
python tools/preflight_release.py --check static
python tools/preflight_release.py --check workflows --verbose
python tools/preflight_release.py --skip manager-meta # offline mode
Exit codes:
0 all run checks passed
1 one or more checks failed
2 invalid CLI usage
"""
from __future__ import annotations
import argparse
import ast
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Iterable
REPO_ROOT = Path(__file__).resolve().parent.parent
REPO_URL = "https://github.com/malkuthro/ComfyUI-Koolook"
MANAGER_EXT_MAP_URL = (
"https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/"
"main/extension-node-map.json"
)
class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Refuses to follow a 30x redirect to a non-https target.
`urllib.request.urlopen` follows redirects by default; a one-time
https URL check at the call site doesn't protect against an
intermediate hop redirecting to file:// or http:// (Bandit B310's
real concern). This handler re-validates each redirect target.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
if not newurl.startswith("https://"):
raise urllib.error.URLError(
f"refusing redirect to non-https scheme: {newurl}"
)
return super().redirect_request(req, fp, code, msg, headers, newurl)
# ANSI escape codes (https://en.wikipedia.org/wiki/ANSI_escape_code).
# No-op when piped, redirected, or NO_COLOR is set.
_ANSI = {
"red": "31",
"green": "32",
"yellow": "33",
"cyan": "36",
"dim": "2",
}
def _color(s: str, code: str) -> str:
if not sys.stdout.isatty():
return s
return f"\033[{code}m{s}\033[0m"
def _green(s: str) -> str: return _color(s, _ANSI["green"])
def _red(s: str) -> str: return _color(s, _ANSI["red"])
def _yellow(s: str) -> str: return _color(s, _ANSI["yellow"])
def _cyan(s: str) -> str: return _color(s, _ANSI["cyan"])
def _dim(s: str) -> str: return _color(s, _ANSI["dim"])
# ───────────────────────────────────────────────────────────────────────
# Check 1 — static (AST extraction)
# ───────────────────────────────────────────────────────────────────────
# Files / folders to skip when walking for *.py.
_AST_SKIP_DIRS = {
"__pycache__", ".venv", ".git", "upscaler_FIX",
"nuke_CAM_exporter", "node_modules",
}
def _iter_python_files() -> Iterable[Path]:
for p in REPO_ROOT.rglob("*.py"):
if any(part in _AST_SKIP_DIRS for part in p.parts):
continue
yield p
def _extract_literal_dict_keys(node: ast.AST) -> set[str]:
"""Pull literal string keys out of an ast.Dict node, ignoring computed keys."""
if not isinstance(node, ast.Dict):
return set()
keys = set()
for k in node.keys:
if isinstance(k, ast.Constant) and isinstance(k.value, str):
keys.add(k.value)
return keys
def check_static(verbose: bool = False) -> tuple[bool, set[str]]:
"""
Walk all .py files, AST-parse each, and collect every literal-dict
`NODE_CLASS_MAPPINGS = {...}` source-side key. Returns (ok, ids).
What this DOES NOT do (and the workflow check is the safety net):
- Follow runtime-computed mappings (dict comprehensions, function
calls like `_namespace_mappings(...)` in the v2_3_3 wrapper).
- Apply `NAMESPACE_SUFFIX` / `SKIP_VERSION_SUFFIX` logic — the
current code only walks `NODE_CLASS_MAPPINGS`, not the fork's
suffix-mangling glue.
For the Radiance Koolook v2_3_3 fork, this happens to work because
every exposed VAE ID is in `SKIP_VERSION_SUFFIX`, so the source-dict
keys match the user-facing IDs. The first time a non-skipped ID lands
in a fork, the workflow-fixture check (4) will surface the mismatch
by reporting the suffixed live ID as "missing" — at that point this
function should grow SKIP_VERSION_SUFFIX extraction.
"""
print(_cyan("[1/4] static — AST extraction of NODE_CLASS_MAPPINGS"))
parse_errors: list[str] = []
all_node_ids: set[str] = set()
files_seen = 0
for py_path in _iter_python_files():
files_seen += 1
try:
source = py_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(py_path))
except (SyntaxError, UnicodeDecodeError) as exc:
parse_errors.append(f"{py_path.relative_to(REPO_ROOT)}: {exc}")
continue
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if (
isinstance(target, ast.Name)
and target.id == "NODE_CLASS_MAPPINGS"
):
keys = _extract_literal_dict_keys(node.value)
all_node_ids.update(keys)
if verbose and keys:
rel = py_path.relative_to(REPO_ROOT)
print(_dim(f" {rel}: {sorted(keys)}"))
if parse_errors:
print(_red(f" FAIL — {len(parse_errors)} parse errors:"))
for err in parse_errors:
print(_red(f" {err}"))
return False, all_node_ids
if not all_node_ids:
print(_red(" FAIL — no NODE_CLASS_MAPPINGS literal dicts found anywhere"))
return False, all_node_ids
print(
_green(f" PASS — {files_seen} *.py files parsed, "
f"{len(all_node_ids)} unique node IDs collected"
+ (":" if verbose else ""))
)
if verbose:
for nid in sorted(all_node_ids):
print(_dim(f" {nid}"))
return True, all_node_ids
# ───────────────────────────────────────────────────────────────────────
# Check 2 — dispatch (stub-VAE roundtrip)
# ───────────────────────────────────────────────────────────────────────
def check_dispatch(verbose: bool = False) -> bool:
"""
Run the v2_3_3 VAE wrapper through every rank/VAE-type combination using
a FakeTensor stub. No torch dependency — the stub mimics just enough of
`torch.Tensor` to drive the dispatch logic.
Implementation lives in `tools/_preflight_dispatch.py` (extracted to keep
this orchestrator under the project's ~400-line file-size guideline).
"""
print(_cyan("[2/4] dispatch — VAE rank/VAE-type branches"))
# Lazy import: the sibling module shares the same directory as this file
# but isn't importable as `tools._preflight_dispatch` unless tools/ is on
# sys.path. Load it directly via importlib so working dir doesn't matter.
import importlib.util
spec = importlib.util.spec_from_file_location(
"_preflight_dispatch",
Path(__file__).resolve().parent / "_preflight_dispatch.py",
)
dispatch = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dispatch)
success, failures = dispatch.run(verbose=verbose, repo_root=REPO_ROOT)
if failures:
print(_red(f" FAIL — {len(failures)} dispatch branch(es) broken:"))
for f in failures:
print(_red(f" {f}"))
return False
print(_green(" PASS — all 5 dispatch branches OK"))
return success
# ───────────────────────────────────────────────────────────────────────
# Check 3 — manager-meta (extension-node-map drift)
# ───────────────────────────────────────────────────────────────────────
def check_manager_meta(actual_ids: set[str], verbose: bool = False) -> bool:
"""
Compare ComfyUI-Manager's upstream extension-node-map entry for our repo
against the AST-extracted node IDs. Reports phantom IDs (in upstream but
not in our code) and missing IDs (in our code but not in upstream).
"""
print(_cyan("[3/4] manager-meta — extension-node-map.json drift"))
if not MANAGER_EXT_MAP_URL.startswith("https://"):
# Hardcoded URL above is https; this guard ensures any future edit
# can't accidentally introduce a non-https scheme.
print(_yellow(" SKIP — manager-meta URL is not https"))
return True
try:
# Custom opener with a redirect handler that re-validates each hop
# for an https scheme — closes the gap that urlopen by default would
# follow a 30x to http:// or file:// silently.
opener = urllib.request.build_opener(_HttpsOnlyRedirectHandler())
# Bandit B310: scheme is enforced by the guard above + the redirect
# handler below; the URL is hardcoded to a github.com raw path.
with opener.open(MANAGER_EXT_MAP_URL, timeout=20) as resp: # nosec B310
data = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as exc:
print(_yellow(f" SKIP — could not fetch upstream map: {exc}"))
return True
entry = data.get(REPO_URL)
if entry is None:
print(_yellow(f" SKIP — no entry for {REPO_URL} in upstream map yet"))
return True
# Defensive shape-check: upstream entry should be `[list_of_ids, metadata]`.
# Without this, a future schema change at upstream would silently produce
# an empty `set()` here and report every one of our IDs as `missing`.
if not (isinstance(entry, list) and entry and isinstance(entry[0], list)):
print(_yellow(
f" SKIP — upstream entry has unexpected shape: "
f"{type(entry).__name__}"
))
return True
upstream_ids = {x for x in entry[0] if isinstance(x, str)}
phantom = upstream_ids - actual_ids
missing = actual_ids - upstream_ids
if phantom or missing:
print(_red(" FAIL — drift between code and Manager metadata:"))
if phantom:
print(_red(f" Manager lists {len(phantom)} ID(s) not in our code:"))
for x in sorted(phantom):
print(_red(f" - {x}"))
if missing:
print(_red(f" Our code registers {len(missing)} ID(s) not in Manager:"))
for x in sorted(missing):
print(_red(f" - {x}"))
print(
_yellow(
" -> Manager metadata is community-maintained. "
"Drift here means an issue or PR to ltdrdata/ComfyUI-Manager. "
"See issue #44 for the canonical write-up."
)
)
return False
print(_green(f" PASS — Manager metadata matches our {len(actual_ids)} IDs"))
return True
# ───────────────────────────────────────────────────────────────────────
# Check 4 — workflows (fixture node-ID stability)
# ───────────────────────────────────────────────────────────────────────
# Heuristic: a node `type` value in a workflow JSON is treated as a Koolook
# pack ID if it matches any of these patterns.
_KOOLOOK_ID_PATTERNS = [
re.compile(r"(?i)koolook"), # contains "koolook" anywhere
re.compile(r"^Easy[A-Z_]"), # starts with "Easy" + capital/underscore
re.compile(r"^easy_[A-Za-z]"), # starts with "easy_" + any letter
# (covers easy_ImageBatch with capital I)
]
def _looks_like_koolook_id(nid: str) -> bool:
return any(p.search(nid) for p in _KOOLOOK_ID_PATTERNS)
def _extract_workflow_node_types(data: Any) -> set[str]:
"""
Extract node `type` / `class_type` strings from a workflow JSON.
Handles two ComfyUI export formats:
- Canvas format: top-level dict with a `nodes` array of {type, ...}.
- API format: top-level dict where every value is a node spec
with a `class_type` key.
Returns an empty set if the shape doesn't match either format —
caller decides whether that's a soft or hard failure.
"""
if not isinstance(data, dict):
return set()
# Canvas format
if isinstance(data.get("nodes"), list):
return {
n["type"] for n in data["nodes"]
if isinstance(n, dict) and isinstance(n.get("type"), str)
}
# API format — only accept if EVERY value has class_type, so a malformed
# canvas (e.g. nodes: null) doesn't accidentally fall through and pull
# class_type from unrelated keys.
values = list(data.values())
if values and all(
isinstance(v, dict) and isinstance(v.get("class_type"), str) for v in values
):
return {v["class_type"] for v in values}
return set()
def check_workflows(actual_ids: set[str], verbose: bool = False) -> bool:
print(_cyan("[4/4] workflows — fixture node-ID stability"))
fixtures_dir = REPO_ROOT / "tests" / "workflows"
if not fixtures_dir.is_dir():
print(_yellow(" SKIP — no tests/workflows/ folder yet"))
return True
fixtures = sorted(fixtures_dir.glob("*.json"))
if not fixtures:
# If the dir exists with a README but contains no *.json, that's a
# regression in fixture coverage (someone deleted them or typoed
# the dir name). Fail loudly rather than silently pass.
if (fixtures_dir / "README.md").exists():
print(_red(
" FAIL — fixtures dir exists with README but no *.json files. "
"If this is intentional, delete the README too."
))
return False
print(_yellow(" SKIP — no *.json fixtures present"))
return True
failures: list[str] = []
total_koolook_refs = 0
for fx in fixtures:
try:
data = json.loads(fx.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
failures.append(f"{fx.name}: invalid JSON ({exc})")
continue
types = _extract_workflow_node_types(data)
# Two-pronged matching:
# (a) heuristic match — anything that LOOKS like a Koolook ID by
# the name pattern. Catches drift where a fixture references
# a renamed/removed Koolook ID even if the fork's suffixing
# is opaque to the static check.
# (b) actual_ids match — anything in the AST-extracted set is by
# definition Koolook. Catches IDs that don't fit the heuristic
# (e.g. future Koolook nodes named without an "Easy"/"easy_"
# prefix and without "koolook" in the ID itself).
# Union for the "this fixture references N Koolook IDs" report;
# heuristic-minus-actual for the "missing ID" failure flag.
koolook_by_heuristic = {t for t in types if _looks_like_koolook_id(t)}
koolook_by_actual = types & actual_ids
koolook_total = koolook_by_heuristic | koolook_by_actual
missing = koolook_by_heuristic - actual_ids
total_koolook_refs += len(koolook_total)
if verbose:
print(_dim(
f" {fx.name}: {len(types)} types, "
f"{len(koolook_total)} Koolook (heuristic={len(koolook_by_heuristic)}, "
f"actual={len(koolook_by_actual)}), "
f"{len(missing)} missing"
))
if missing:
for nid in sorted(missing):
failures.append(f"{fx.name}: {nid} (no longer registered)")
if failures:
print(_red(f" FAIL — {len(failures)} workflow regression(s):"))
for f in failures:
print(_red(f" {f}"))
print(
_yellow(
" -> Either add a back-compat alias for the missing ID, "
"or update the fixture if the rename was intentional + announced."
)
)
return False
print(_green(
f" PASS — {len(fixtures)} fixture(s) reference "
f"{total_koolook_refs} Koolook node ID(s), all currently registered"
))
return True
# ───────────────────────────────────────────────────────────────────────
# Orchestration
# ───────────────────────────────────────────────────────────────────────
CHECKS = ("static", "dispatch", "manager-meta", "workflows")
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--check", choices=CHECKS, action="append",
help="Run only the named check (repeatable). Default: run all.",
)
parser.add_argument(
"--skip", choices=CHECKS, action="append",
help="Skip the named check (repeatable).",
)
parser.add_argument(
"-v", "--verbose", action="store_true",
help="Print per-check details.",
)
args = parser.parse_args()
selected = set(args.check or CHECKS)
skipped = set(args.skip or ())
to_run = [c for c in CHECKS if c in selected and c not in skipped]
if not to_run:
print(_red("No checks selected after applying --check / --skip"), file=sys.stderr)
return 2
print(_cyan(f"Pre-flight running: {', '.join(to_run)}"))
print()
actual_ids: set[str] = set()
results: dict[str, bool] = {}
if "static" in to_run:
ok, actual_ids = check_static(verbose=args.verbose)
results["static"] = ok
print()
if "dispatch" in to_run:
results["dispatch"] = check_dispatch(verbose=args.verbose)
print()
if "manager-meta" in to_run:
if not actual_ids and "static" not in to_run:
ok, actual_ids = check_static(verbose=False)
results["manager-meta"] = check_manager_meta(actual_ids, verbose=args.verbose)
print()
if "workflows" in to_run:
if not actual_ids and "static" not in to_run:
ok, actual_ids = check_static(verbose=False)
results["workflows"] = check_workflows(actual_ids, verbose=args.verbose)
print()
failed = [n for n, ok in results.items() if not ok]
print(_cyan("Summary"))
for n in to_run:
marker = _green("PASS") if results.get(n) else _red("FAIL")
print(f" {marker} {n}")
if failed:
print()
print(_red(f"{len(failed)} check(s) failed: {', '.join(failed)}"))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())