* docs+tooling: VAE user guide + versioning ref + 3 skills + preflight CI
Documentation
- New user-guide tree: docs/user_guide/nodes/radiance_koolook_v2_3_3/
with README, per-node pages for Easy_hdr_VAE_encode/decode, an
encode/decode pairing guide (4 worked examples + ASCII connectivity
diagram), and an HDR modes deep-dive (per-pixel comparison table).
Folder name mirrors the code path under forks/radiance_koolook/versions/.
- New cross-cutting reference: docs/reference/versioning.md explains the
three independent version axes (pack version, fork wrapper version,
upstream pinned commit), when each one bumps, and which skill/doc
owns it. Plus the v1/v2 node-ID suffix policy.
- Cross-links updated in docs/user_guide/README.md, docs/reference/
README.md, docs/maintainers/README.md.
Skills (all under .claude/skills/)
- docs-sync — controlled find-and-replace for version tags across docs,
code, and manifests; --scope and --dry-run flags.
- menu — lists all project skills with one-liners.
- preflight-release — companion doc for tools/preflight_release.py.
Pre-flight tooling
- tools/preflight_release.py — four checks, exit-code-driven for CI:
(1) static AST extraction of NODE_CLASS_MAPPINGS keys (no torch needed),
(2) stub-VAE dispatch test verifying all 5 rank/VAE-type branches,
(3) drift check against ComfyUI-Manager's extension-node-map.json
(catches the issue #44 phantom-nodes class of bug automatically),
(4) saved-workflow regression check across tests/workflows/*.json.
- .github/workflows/ci.yml — new "Pre-flight Release" job runs checks
1, 2, and 4 on every PR; check 3 (manager-meta) skipped pending the
upstream issue #44 fix.
- tests/workflows/ — 2 starter fixtures (Depth-Anything3, Hires-FIX-wanT2V)
+ README documenting the contribution convention. Coverage grows as
new fixtures are added; no code changes needed.
All four offline pre-flight checks pass on this branch. The
manager-meta check correctly identifies the 39 phantom IDs documented
in issue #44.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(preflight): satisfy ruff lint + bandit security gate
* fix(preflight): address blocking review findings
Three fixes from /review-pr feedback on this PR:
1. Sanitize tests/workflows/*.json — removed 4 unique absolute paths that
leaked internal job/client info ('baconx/Toy-Hix/9110',
'menneskehavn_3029', etc.). Replaced with placeholders like
'path/to/sequence_a' and 'path/to/source_video'. Both fixtures still
parse as valid JSON; grep for the old patterns returns zero hits.
2. Broaden the Koolook-ID heuristic regex from '^easy_[a-z]' to
'^easy_[A-Za-z]'. The old pattern excluded 'easy_ImageBatch' (capital I
after the underscore), which would have silently bypassed the
workflow-fixture stability check for that node. Verified the new
pattern correctly matches it.
3. Wrap check_dispatch() body in try/finally and track every sys.modules
key the function injects in a list, so cleanup runs on the success
path AND on any exception during exec_module / VAE-constructor
execution. Previously only the three torch* stubs were popped, never
the three _pf_pkg* entries — and even those weren't popped on
exception paths. Verified zero leaked modules after the function
returns.
The five dispatch test cases were factored into a helper
_check_dispatch_run_cases() so the try/finally wrapping stays focused on
module injection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(preflight): address remaining review findings
Follow-up to 7844037 — picks up all the HIGH / MEDIUM / LOW review
findings that were deferred. Pre-flight still passes locally.
HIGH
- Extract check_dispatch (~195 lines) to tools/_preflight_dispatch.py.
Main script drops from 680 → 508 lines. Module exposes a single
run(verbose, repo_root) -> (bool, list[str]) entry; main script keeps
the [2/4] header + PASS/FAIL line.
- Install _HttpsOnlyRedirectHandler that re-validates each redirect
hop's scheme. Closes the gap that urlopen would otherwise follow a
30x to http:// or file:// silently. The Bandit B310 suppression
comment now accurately describes the protection.
- check_static docstring now honestly states it only walks
NODE_CLASS_MAPPINGS literal dicts and does NOT apply
NAMESPACE_SUFFIX / SKIP_VERSION_SUFFIX logic. Notes that the workflow
check (4) is the safety net that surfaces drift the day a non-skipped
ID lands.
- check_workflows now FAILS when the fixtures dir exists with a README
but contains no *.json files — previously silently SKIPPED. If
someone deletes fixtures or typoes the dir, that's a regression in
test coverage and should fail loudly.
MEDIUM
- check_manager_meta validates the upstream entry shape before diffing.
Previous code silently produced an empty set if the schema changed,
reporting every one of our IDs as 'missing'. Now SKIPs with a clear
message when shape is unexpected.
- check_workflows accumulates total_koolook_refs inside the main loop;
drops the redundant second-pass that re-read every fixture file just
to compute the summary count.
- _extract_workflow_node_types uses explicit detection: a canvas-format
file with nodes: null no longer falls through to API-format and pulls
class_type from unrelated keys.
- check_static return type simplified from (bool, set, list) to
(bool, set). Third element was discarded at every call site.
- check_workflows now does two-pronged Koolook-ID matching: heuristic
(catches drift even if static missed an ID) plus actual_ids (catches
IDs that don't fit the heuristic, e.g. a future Koolook node without
Easy / easy_ / koolook in its name).
LOW
- ANSI escape codes named in a constants dict (_ANSI["green"] etc.)
instead of bare string literals.
- Removed dead 'cases: list[tuple[str, callable]] = []' initializer
(was shadowed three statements later).
- check_static's per-ID listing now gated behind --verbose, matching
check_dispatch and check_workflows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""
|
|
Stub-VAE dispatch test for the v2_3_3 wrapper, extracted from
|
|
`preflight_release.py` to keep the main script under the project's
|
|
~400-line file-size guideline.
|
|
|
|
This module:
|
|
- Builds a `FakeTensor` (`FT`) class that mimics just enough of
|
|
`torch.Tensor` to drive the wrapper's dispatch logic (no real math).
|
|
- Builds a `torch` stub module + `torch.nn` / `torch.nn.functional`
|
|
submodule stubs.
|
|
- Loads the v2_3_3 wrapper code (`color_helpers.py` + `nodes_vae.py`)
|
|
via `importlib` against the stub torch.
|
|
- Runs five test cases through `Easy_hdr_VAE_encode` / `_decode`,
|
|
asserting that the per-case dispatch path appears in `debug_info`.
|
|
- Cleans up `sys.modules` in a `finally` block — including the
|
|
`_pf_pkg*` entries — so a partial-load failure can't leak the stub
|
|
torch into anything else running in the same process.
|
|
|
|
Public surface: `run(verbose: bool, repo_root: Path) -> tuple[bool, list[str]]`.
|
|
The caller (in `preflight_release.py`) is responsible for the `[2/4] …`
|
|
header and the PASS/FAIL line; this module just executes and reports.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
# FakeTensor stub
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class FakeTensor:
|
|
"""Tensor-shaped duck type that records its shape and supports the
|
|
handful of operations the v2_3_3 wrapper does at dispatch time."""
|
|
|
|
def __init__(self, shape):
|
|
self.shape = tuple(shape)
|
|
self.dtype = "float32"
|
|
self.device = "cpu"
|
|
|
|
@property
|
|
def ndim(self):
|
|
return len(self.shape)
|
|
|
|
@property
|
|
def T(self):
|
|
s = list(self.shape)
|
|
if len(s) >= 2:
|
|
s[-1], s[-2] = s[-2], s[-1]
|
|
return FakeTensor(s)
|
|
|
|
def clone(self):
|
|
return FakeTensor(self.shape)
|
|
|
|
def float(self):
|
|
return self
|
|
|
|
def __getitem__(self, key):
|
|
# Slicing patterns the wrapper actually uses:
|
|
# pixels[:, fi, ...] → drop dim 1, keep rest
|
|
# latent[:, :, fi, ...] → drop dim 2, keep rest
|
|
# img[..., :3] → keep leading dims, last dim = 3
|
|
if isinstance(key, tuple) and len(key) == 3 and key[2] is Ellipsis:
|
|
return FakeTensor((self.shape[0],) + self.shape[2:])
|
|
if isinstance(key, tuple) and len(key) == 4 and key[3] is Ellipsis:
|
|
return FakeTensor((self.shape[0], self.shape[1]) + self.shape[3:])
|
|
if isinstance(key, tuple) and key[0] is Ellipsis:
|
|
return FakeTensor(self.shape[:-1] + (3,))
|
|
return self
|
|
|
|
def reshape(self, *a):
|
|
return FakeTensor(a)
|
|
|
|
def to(self, **k):
|
|
return self
|
|
|
|
def __mul__(self, o): return self
|
|
def __rmul__(self, o): return self
|
|
def __pow__(self, o): return self
|
|
def __sub__(self, o): return self
|
|
def clamp(self, *a, **k): return self
|
|
|
|
def __matmul__(self, o):
|
|
return FakeTensor(self.shape[:-1] + (o.shape[-1],))
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
# torch stub
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _build_torch_stub() -> tuple[types.ModuleType, types.ModuleType, types.ModuleType]:
|
|
"""Returns the (`torch`, `torch.nn`, `torch.nn.functional`) stub triple."""
|
|
torch_stub = types.ModuleType("torch")
|
|
torch_stub.Tensor = FakeTensor
|
|
torch_stub.stack = lambda ts, dim=0: (
|
|
lambda s: (s.insert(dim, len(ts)), FakeTensor(s))[1]
|
|
)(list(ts[0].shape))
|
|
torch_stub.cat = lambda ts, dim=0: (
|
|
lambda s: (s.__setitem__(dim, sum(x.shape[dim] for x in ts)), FakeTensor(s))[1]
|
|
)(list(ts[0].shape))
|
|
torch_stub.where = lambda c, a, b: a
|
|
torch_stub.sign = lambda x: x
|
|
torch_stub.abs = lambda x: x
|
|
torch_stub.pow = lambda x, p: x
|
|
torch_stub.tanh = lambda x: x
|
|
torch_stub.clamp = lambda x, *a, **k: x
|
|
torch_stub.tensor = lambda *a, **k: FakeTensor((3, 3))
|
|
torch_stub.float32 = "float32"
|
|
torch_stub.zeros = lambda shape, dtype=None, device=None: FakeTensor(shape)
|
|
|
|
nn_F = types.ModuleType("torch.nn.functional")
|
|
nn_F.pad = lambda x, *a, **k: x
|
|
nn_F.avg_pool2d = lambda x, *a, **k: x
|
|
|
|
nn_mod = types.ModuleType("torch.nn")
|
|
nn_mod.functional = nn_F
|
|
torch_stub.nn = nn_mod
|
|
|
|
return torch_stub, nn_mod, nn_F
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
# Stub VAE classes
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class Fake2DVAE:
|
|
"""A vanilla 2D image VAE — no `latent_dim` attribute, so the wrapper
|
|
treats it as the standard SD/SDXL/Flux case."""
|
|
|
|
def encode(self, pixels):
|
|
return FakeTensor(
|
|
(1, 16, pixels.shape[1] // 8, pixels.shape[2] // 8)
|
|
)
|
|
|
|
def decode(self, lat):
|
|
return FakeTensor(
|
|
(lat.shape[0], lat.shape[2] * 8, lat.shape[3] * 8, 3)
|
|
)
|
|
|
|
|
|
class Fake3DVAE:
|
|
"""A 3D-aware video VAE — exposes `latent_dim = 3` so the wrapper
|
|
detects it and short-circuits the per-frame iteration on encode."""
|
|
|
|
latent_dim = 3
|
|
|
|
def encode(self, pixels):
|
|
if pixels.ndim == 5:
|
|
return FakeTensor((
|
|
1, 16,
|
|
pixels.shape[1] // 4,
|
|
pixels.shape[2] // 8,
|
|
pixels.shape[3] // 8,
|
|
))
|
|
return FakeTensor((1, 16, pixels.shape[1] // 8, pixels.shape[2] // 8))
|
|
|
|
def decode(self, lat):
|
|
if lat.ndim == 5:
|
|
return FakeTensor((
|
|
1, lat.shape[2] * 4,
|
|
lat.shape[3] * 8, lat.shape[4] * 8, 3,
|
|
))
|
|
return FakeTensor((lat.shape[0], lat.shape[2] * 8, lat.shape[3] * 8, 3))
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
# Public entry point
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def run(verbose: bool, repo_root: Path) -> tuple[bool, list[str]]:
|
|
"""Run the five dispatch test cases and report.
|
|
|
|
Returns:
|
|
(success, failures)
|
|
- `success`: True if every case passed.
|
|
- `failures`: list of "<case_name>: <error>" strings (empty on success).
|
|
|
|
On success, a per-case "OK" line is printed when `verbose=True`.
|
|
Header / PASS-FAIL formatting is the caller's responsibility — this
|
|
module is the mechanical part.
|
|
"""
|
|
torch_stub, nn_mod, nn_F = _build_torch_stub()
|
|
pkg_path = repo_root / "forks" / "radiance_koolook" / "versions" / "v2_3_3"
|
|
|
|
# Track every sys.modules key we add so the finally block can pop them
|
|
# cleanly even if exec_module / constructors raise.
|
|
injected_modules: list[str] = []
|
|
|
|
try:
|
|
sys.modules["torch"] = torch_stub
|
|
injected_modules.append("torch")
|
|
sys.modules["torch.nn"] = nn_mod
|
|
injected_modules.append("torch.nn")
|
|
sys.modules["torch.nn.functional"] = nn_F
|
|
injected_modules.append("torch.nn.functional")
|
|
|
|
pkg_spec = importlib.machinery.ModuleSpec(
|
|
name="_pf_pkg", loader=None, is_package=True
|
|
)
|
|
pkg_spec.submodule_search_locations = [str(pkg_path)]
|
|
pkg = importlib.util.module_from_spec(pkg_spec)
|
|
sys.modules["_pf_pkg"] = pkg
|
|
injected_modules.append("_pf_pkg")
|
|
|
|
ch_spec = importlib.util.spec_from_file_location(
|
|
"_pf_pkg.color_helpers", str(pkg_path / "color_helpers.py")
|
|
)
|
|
ch_mod = importlib.util.module_from_spec(ch_spec)
|
|
sys.modules["_pf_pkg.color_helpers"] = ch_mod
|
|
injected_modules.append("_pf_pkg.color_helpers")
|
|
ch_spec.loader.exec_module(ch_mod)
|
|
|
|
nv_spec = importlib.util.spec_from_file_location(
|
|
"_pf_pkg.nodes_vae", str(pkg_path / "nodes_vae.py")
|
|
)
|
|
nv_mod = importlib.util.module_from_spec(nv_spec)
|
|
sys.modules["_pf_pkg.nodes_vae"] = nv_mod
|
|
injected_modules.append("_pf_pkg.nodes_vae")
|
|
nv_spec.loader.exec_module(nv_mod)
|
|
|
|
return _run_cases(nv_mod, verbose)
|
|
finally:
|
|
for mod in injected_modules:
|
|
sys.modules.pop(mod, None)
|
|
|
|
|
|
def _run_cases(nv_mod, verbose: bool) -> tuple[bool, list[str]]:
|
|
"""The actual five dispatch test cases. Receives the loaded
|
|
`nodes_vae` module — which now sees our stub torch."""
|
|
enc = nv_mod.Easy_hdr_VAE_encode()
|
|
dec = nv_mod.Easy_hdr_VAE_decode()
|
|
common_enc = dict(
|
|
source_space="Raw", hdr_mode="Passthrough",
|
|
exposure=0.0, alpha_handling="Preserve", latent_sampling="sample",
|
|
)
|
|
common_dec = dict(
|
|
target_space="Raw", exposure_adjust=0.0,
|
|
hdr_mode="Clip (SDR)", source_space="Linear",
|
|
)
|
|
|
|
def case_4d_image():
|
|
lat, dbg, _ = enc.encode(FakeTensor((2, 64, 64, 3)), Fake2DVAE(), **common_enc)
|
|
assert lat["samples"].ndim == 4 and "path=image" in dbg
|
|
|
|
def case_5d_2dvae():
|
|
lat, dbg, _ = enc.encode(FakeTensor((1, 8, 64, 64, 3)), Fake2DVAE(), **common_enc)
|
|
assert (
|
|
lat["samples"].ndim == 5
|
|
and lat["samples"].shape[2] == 8
|
|
and "path=video-iter" in dbg
|
|
)
|
|
|
|
def case_5d_3dvae():
|
|
lat, dbg, _ = enc.encode(FakeTensor((1, 8, 64, 64, 3)), Fake3DVAE(), **common_enc)
|
|
assert "path=video-3d" in dbg
|
|
|
|
def case_5d_lat_decode():
|
|
img, dbg = dec.decode(
|
|
{"samples": FakeTensor((1, 16, 8, 8, 8))}, Fake2DVAE(), **common_dec
|
|
)
|
|
assert img.ndim == 4 and img.shape[0] == 8 and "path=video-iter" in dbg
|
|
|
|
def case_4d_lat_decode():
|
|
img, dbg = dec.decode(
|
|
{"samples": FakeTensor((2, 16, 8, 8))}, Fake2DVAE(), **common_dec
|
|
)
|
|
assert "path=image" in dbg
|
|
|
|
cases = [
|
|
("4D image, 2D-VAE", case_4d_image),
|
|
("5D + 2D-VAE iter", case_5d_2dvae),
|
|
("5D + 3D-VAE pass", case_5d_3dvae),
|
|
("5D latent decode iter", case_5d_lat_decode),
|
|
("4D latent decode pass", case_4d_lat_decode),
|
|
]
|
|
|
|
failures: list[str] = []
|
|
for name, fn in cases:
|
|
try:
|
|
fn()
|
|
if verbose:
|
|
# Caller can repaint this with color if it wants —
|
|
# here we just emit a plain line so the module stays
|
|
# color-helper-free.
|
|
print(f" OK {name}")
|
|
except Exception as exc:
|
|
failures.append(f"{name}: {type(exc).__name__}: {exc}")
|
|
|
|
return (not failures), failures
|