| """Validate the two shipped workflows against the live node, not against a guess. |
| |
| Checks the things that actually break on someone else's machine: a node type |
| that is not registered, a widget count that disagrees with INPUT_TYPES, a link |
| that points at nothing, and a plan that does not survive the real parsers. |
| """ |
| import io |
| import json |
| import os |
| import sys |
| import types |
|
|
| HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| |
| |
| sys.path.insert(0, os.path.dirname(os.path.dirname(HERE))) |
| pkg = types.ModuleType("h3p") |
| pkg.__path__ = [HERE] |
| sys.modules["h3p"] = pkg |
|
|
| from h3p import refs as R |
| from h3p import plan as PL |
|
|
| |
| from h3p import h3_ref_chain as H3 |
|
|
| CORE = {"UNETLoader", "CLIPLoader", "VAELoader", "CreateVideo", "SaveVideo", |
| "SaveImage", "Note", "MarkdownNote", "PreviewAny", "PreviewImage"} |
| OURS = {"HandTieClips", "HTCChainPreview", "HTCSeamReport"} |
| |
| |
| |
| DEPS = {"LTX_lora_loader": "ComfyUI-PlagueKind-Nodes", |
| "H3AdaLNLoRAFix": "ComfyUI-PlagueKind-Nodes", |
| "H3SLAAttention": "ComfyUI-PlagueKind-Nodes", |
| "MiniMaxLowVRAMAttention": "ComfyUI-KJNodes (experimental)", |
| "ModelPreviewOverrideKJ": "ComfyUI-KJNodes"} |
| |
| MODEL_PATH = ["UNETLoader", "LTX_lora_loader", "H3AdaLNLoRAFix", |
| "MiniMaxLowVRAMAttention", "H3SLAAttention", |
| "ModelPreviewOverrideKJ", "HandTieClips"] |
| |
| |
| CLIP_PATH = ["CLIPLoader", "LTX_lora_loader", "HandTieClips"] |
| FAIL = [] |
|
|
|
|
| def ck(label, ok, detail=""): |
| print(" %s %s%s" % ("ok " if ok else "FAIL", label, |
| (" " + detail) if detail else "")) |
| if not ok: |
| FAIL.append(label) |
|
|
|
|
| SOCKET_TYPES = {"MODEL", "CLIP", "VAE", "IMAGE", "AUDIO", "LATENT", |
| "CONDITIONING", "VIDEO"} |
|
|
|
|
| def widget_names(): |
| """The flat widget list, in the order widgets_values is indexed by. |
| |
| A widget is any input whose type is a primitive or a combo AND which is not |
| marked `forceInput` -- `continuity_state` is a STRING but arrives on a wire. |
| `seed` occupies TWO entries because the frontend appends |
| control_after_generate directly after it. |
| """ |
| it = H3.HandTieClips.INPUT_TYPES() |
| names = [] |
| for section in ("required", "optional"): |
| for name, spec in (it.get(section) or {}).items(): |
| t = spec[0] |
| cfg = spec[1] if len(spec) > 1 else {} |
| if isinstance(t, str) and t in SOCKET_TYPES: |
| continue |
| if cfg.get("forceInput"): |
| continue |
| names.append(name) |
| if name == "seed": |
| names.append("control_after_generate") |
| return names |
|
|
|
|
| def main(): |
| expect = widget_names() |
| print("node declares %d widgets (control_after_generate included)\n" |
| % len(expect)) |
|
|
| for fn in ("HandTieClips_Starter.json", "HandTieClips_Showcase.json"): |
| p = os.path.join(HERE, "workflows", fn) |
| print(fn) |
| wf = json.load(io.open(p, encoding="utf-8")) |
| nodes = wf["nodes"] |
| byid = {n["id"]: n for n in nodes} |
| types_used = {n["type"] for n in nodes} |
|
|
| allowed = CORE | OURS | set(DEPS) |
| ck("only core, this pack and declared deps", types_used <= allowed, |
| str(sorted(types_used - allowed)) if types_used - allowed |
| else "%d types" % len(types_used)) |
| ck("no LoadImage", not any(n["type"] == "LoadImage" for n in nodes)) |
|
|
| chain = next(n for n in nodes if n["type"] == "HandTieClips") |
|
|
| |
| |
| |
| def walk_wire(path, kind): |
| seen = [path[0]] |
| cur = next(n for n in nodes if n["type"] == path[0]) |
| for _ in range(len(path)): |
| nxt = [l for l in wf["links"] |
| if l[1] == cur["id"] and l[5] == kind] |
| if not nxt: |
| break |
| cur = byid[nxt[0][3]] |
| seen.append(cur["type"]) |
| return seen |
|
|
| m = walk_wire(MODEL_PATH, "MODEL") |
| ck("MODEL wire runs the whole turbo stack", m == MODEL_PATH, |
| " -> ".join(m)) |
| c = walk_wire(CLIP_PATH, "CLIP") |
| ck("CLIP reaches the chain through the LoRA loader", c == CLIP_PATH, |
| " -> ".join(c)) |
| ins = [i["name"] for i in chain["inputs"]] |
| dead = [i for i in ins |
| if i.startswith("ref_image_") and i != "ref_image_size" |
| or i in ("reference_video", "voice", "start_image")] |
| ck("no removed sockets", not dead, str(dead)) |
| ck("widget count matches INPUT_TYPES", |
| len(chain["widgets_values"]) == len(expect), |
| "%d vs %d" % (len(chain["widgets_values"]), len(expect))) |
|
|
| wv = dict(zip(expect, chain["widgets_values"])) |
| ck("control_after_generate=fixed", wv["control_after_generate"] == "fixed") |
|
|
| |
| lids = {l[0] for l in wf["links"]} |
| used = set() |
| for n in nodes: |
| for i in n.get("inputs", []): |
| if i.get("link") is not None: |
| used.add(i["link"]) |
| for o in n.get("outputs", []): |
| used.update(o.get("links") or []) |
| ck("link table consistent", lids == used, |
| "table=%s used=%s" % (sorted(lids), sorted(used))) |
| ck("every link endpoint exists", |
| all(l[1] in byid and l[3] in byid for l in wf["links"])) |
| ck("all four sockets fed", |
| all(chain["inputs"][k]["link"] is not None for k in range(4))) |
|
|
| |
| shots = PL.parse_plan(wv["shot_plan"]) |
| n_shots = len(shots["shots"]) if isinstance(shots, dict) else len(shots) |
| ck("shot plan parses", n_shots == int(wv["chains"]), |
| "%d shots, chains=%s" % (n_shots, wv["chains"])) |
|
|
| rp = R.parse_ref_plan(wv["ref_plan"]) |
| refs = rp["refs"] |
| ck("ref plan parses", True, "%d refs" % len(refs)) |
|
|
| |
| declared = {r["tag"] for r in refs} |
| import re |
| body = [s["beat"] for s in (shots["shots"] if isinstance(shots, dict) |
| else shots)] |
| tags = set() |
| for b in body: |
| tags |= set(re.findall(r"@([A-Za-z0-9_]+)", b or "")) |
| ck("every @tag is declared", tags <= declared, |
| "undeclared=%s" % sorted(tags - declared) if tags - declared |
| else "tags=%s" % sorted(tags)) |
|
|
| |
| bad = [] |
| for hop, b in enumerate(body): |
| want = set(re.findall(r"@([A-Za-z0-9_]+)", b or "")) |
| active = R.active_refs(refs, hop, {r["slot"] for r in refs |
| if r["file"]}) |
| have = set(R.ordinals(active)) | set(R.subjects(refs)) |
| miss = want - have |
| if miss: |
| bad.append("hop %d: %s" % (hop + 1, sorted(miss))) |
| ck("every tag is scheduled onto the hops that use it", not bad, |
| "; ".join(bad)) |
|
|
| warn = R.check(rp, {r["slot"] for r in refs if r["file"]}) |
| ck("no register warnings", not warn, " | ".join(warn)) |
|
|
| |
| |
| |
| |
| |
| named = [n["type"] for n in nodes if "widgets_values_named" in n] |
| ck("no stale widgets_values_named", not named, str(sorted(set(named)))) |
|
|
| |
| cards = [n for n in nodes if n["type"] == "MarkdownNote"] |
| if cards: |
| ck("every card is marked and non-empty", |
| all((n.get("properties") or {}).get("htc_card") |
| and (n.get("widgets_values") or [""])[0].strip() |
| for n in cards), |
| "%d cards" % len(cards)) |
| groups = wf.get("groups") or [] |
| ck("a group wraps the board", len(groups) == 1, |
| "%d groups" % len(groups)) |
| if groups: |
| gx, gy, gw, gh = groups[0]["bounding"] |
| outside = [n["title"] for n in cards |
| if not (gx <= n["pos"][0] |
| and gy <= n["pos"][1] |
| and n["pos"][0] + n["size"][0] <= gx + gw |
| and n["pos"][1] + n["size"][1] <= gy + gh)] |
| ck("the group encloses every card", not outside, str(outside)) |
| |
| ck("the board stays left of the graph", |
| all(n["pos"][0] + n["size"][0] <= 0 for n in cards)) |
|
|
| print(" %d nodes, %d links, %d KB\n" |
| % (len(nodes), len(wf["links"]), os.path.getsize(p) // 1024)) |
|
|
| if FAIL: |
| print("%d FAILURE(S): %s" % (len(FAIL), ", ".join(FAIL))) |
| return 1 |
| print("ALL PASS") |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|