diff --git a/.claude/skills/docs-sync/SKILL.md b/.claude/skills/docs-sync/SKILL.md new file mode 100644 index 0000000..a5a0807 --- /dev/null +++ b/.claude/skills/docs-sync/SKILL.md @@ -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 and ; 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 [--scope all|docs|code|manifests] [--dry-run] +``` + +- `` — 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. +- `` — 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 `` and `` are non-empty and distinct. + - Reject if `` looks dangerous as a substring (very short + literal like a single letter or common word). Ask the user to + confirm if `` 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 `` -> `` 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 `` 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 `` 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. diff --git a/.claude/skills/menu/SKILL.md b/.claude/skills/menu/SKILL.md new file mode 100644 index 0000000..a0a14a7 --- /dev/null +++ b/.claude/skills/menu/SKILL.md @@ -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 `/` + (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//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 ` | +| 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//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) diff --git a/.claude/skills/preflight-release/SKILL.md b/.claude/skills/preflight-release/SKILL.md new file mode 100644 index 0000000..60ab69c --- /dev/null +++ b/.claude/skills/preflight-release/SKILL.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ebfd22..94d44bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/maintainers/README.md b/docs/maintainers/README.md index 034219f..f287588 100644 --- a/docs/maintainers/README.md +++ b/docs/maintainers/README.md @@ -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. | diff --git a/docs/reference/README.md b/docs/reference/README.md index b15f3d4..d676f89 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -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 diff --git a/docs/reference/versioning.md b/docs/reference/versioning.md new file mode 100644 index 0000000..c6f5585 --- /dev/null +++ b/docs/reference/versioning.md @@ -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//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//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//versions//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. diff --git a/docs/user_guide/README.md b/docs/user_guide/README.md index dcf8b6b..4a2ead4 100644 --- a/docs/user_guide/README.md +++ b/docs/user_guide/README.md @@ -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). diff --git a/docs/user_guide/nodes/radiance_koolook_v2_3_3/README.md b/docs/user_guide/nodes/radiance_koolook_v2_3_3/README.md new file mode 100644 index 0000000..d5e4f7f --- /dev/null +++ b/docs/user_guide/nodes/radiance_koolook_v2_3_3/README.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) | diff --git a/docs/user_guide/nodes/radiance_koolook_v2_3_3/easy_hdr_vae_decode.md b/docs/user_guide/nodes/radiance_koolook_v2_3_3/easy_hdr_vae_decode.md new file mode 100644 index 0000000..d8fb048 --- /dev/null +++ b/docs/user_guide/nodes/radiance_koolook_v2_3_3/easy_hdr_vae_decode.md @@ -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) diff --git a/docs/user_guide/nodes/radiance_koolook_v2_3_3/easy_hdr_vae_encode.md b/docs/user_guide/nodes/radiance_koolook_v2_3_3/easy_hdr_vae_encode.md new file mode 100644 index 0000000..d453846 --- /dev/null +++ b/docs/user_guide/nodes/radiance_koolook_v2_3_3/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) diff --git a/docs/user_guide/nodes/radiance_koolook_v2_3_3/encode_decode_pairing.md b/docs/user_guide/nodes/radiance_koolook_v2_3_3/encode_decode_pairing.md new file mode 100644 index 0000000..b0313ca --- /dev/null +++ b/docs/user_guide/nodes/radiance_koolook_v2_3_3/encode_decode_pairing.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. diff --git a/docs/user_guide/nodes/radiance_koolook_v2_3_3/hdr_modes.md b/docs/user_guide/nodes/radiance_koolook_v2_3_3/hdr_modes.md new file mode 100644 index 0000000..33e544b --- /dev/null +++ b/docs/user_guide/nodes/radiance_koolook_v2_3_3/hdr_modes.md @@ -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. diff --git a/tests/workflows/Depth-Anything3-v01.json b/tests/workflows/Depth-Anything3-v01.json new file mode 100644 index 0000000..bd5f7e2 --- /dev/null +++ b/tests/workflows/Depth-Anything3-v01.json @@ -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 +} \ No newline at end of file diff --git a/tests/workflows/Hires-FIX-wanT2V-v01.json b/tests/workflows/Hires-FIX-wanT2V-v01.json new file mode 100644 index 0000000..86a2bf2 --- /dev/null +++ b/tests/workflows/Hires-FIX-wanT2V-v01.json @@ -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 +} \ No newline at end of file diff --git a/tests/workflows/README.md b/tests/workflows/README.md new file mode 100644 index 0000000..a38df7a --- /dev/null +++ b/tests/workflows/README.md @@ -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: `--v.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`. diff --git a/tools/_preflight_dispatch.py b/tools/_preflight_dispatch.py new file mode 100644 index 0000000..4d8e550 --- /dev/null +++ b/tools/_preflight_dispatch.py @@ -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 ": " 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 diff --git a/tools/preflight_release.py b/tools/preflight_release.py new file mode 100644 index 0000000..72d0e15 --- /dev/null +++ b/tools/preflight_release.py @@ -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())