File size: 12,220 Bytes
e727aeb 652a63f e727aeb 652a63f e727aeb 652a63f e727aeb 652a63f e727aeb 652a63f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 | r"""Offline regression tests for the 2026-08-30 feature set (docs/DEVLOG.md section 20).
None of these features could be tested in a browser or against a GPU when they
were written, so this is the only thing standing between them and a silent
regression. It runs without a server, without a model and without CUDA.
D:\ComfyUI\venv\Scripts\python.exe tools\check_features.py
Covers:
* tone.anchor_pull -- seam exactness, the cap, the no-op, and the 8-hop
behaviour that justifies the mode existing
* dry_run -- compiles every prompt WITHOUT reaching the sampler,
asserted by booby-trapping every sampler entry point
* render_through -- truncates the render, not the plan
* quality=draft -- forces 0.3 MP and 6 steps
* contact sheet -- builds, and degrades to a placeholder rather than
raising
* seam report -- recovers a planted step at the right frame index
* over-delivery lint -- fires on the real pattern, stays quiet on the traps
"""
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import os
import sys
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
COMFY = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, COMFY)
FAIL = []
def encodable(t):
"""An IMAGE a video encoder can actually open: 4-D, one or more frames,
both dimensions even and >= 2. Checked instead of an exact 1x1 shape
because the exact shape is what shipped broken."""
sh = tuple(t.shape)
return (len(sh) == 4 and sh[0] >= 1 and sh[3] == 3
and sh[1] >= 2 and sh[2] >= 2
and sh[1] % 2 == 0 and sh[2] % 2 == 0)
def ck(name, cond, detail=""):
print(" %-4s %-52s %s" % ("ok" if cond else "FAIL", name, detail))
if not cond:
FAIL.append(name)
def load_pack():
spec = importlib.util.spec_from_file_location(
"htcpack", os.path.join(HERE, "__init__.py"),
submodule_search_locations=[HERE])
m = importlib.util.module_from_spec(spec)
sys.modules["htcpack"] = m
spec.loader.exec_module(m)
return m
def main():
import torch
pack = load_pack()
H3 = sys.modules["htcpack.h3_ref_chain"]
T = sys.modules["htcpack.tone"]
P = sys.modules["htcpack.plan"]
S = sys.modules["htcpack.seam"]
SH = sys.modules["htcpack.sheet"]
torch.manual_seed(0)
N, OV, HW = 120, 22, 32
# ---------------------------------------------------------------- tone
print("\ntone.anchor_pull")
ck("anchor is a mode", "anchor" in T.MODES, str(T.MODES))
hop = (torch.rand(N, HW, HW, 3) * 0.1 + 0.30
+ torch.linspace(0.0, -0.05, N).view(-1, 1, 1, 1)).clamp(0, 1)
out, _ = T.anchor_pull(hop, torch.tensor([0.5, 0.5, 0.5]))
ck("frame 0 is untouched (the seam stays exact)",
float((out[0] - hop[0]).abs().max()) < 1e-6)
ck("the tail is corrected", float((out[-1] - hop[-1]).mean()) > 0.01)
flat = (torch.rand(N, HW, HW, 3) * 0.1 + 0.05).clamp(0, 1)
o2, _ = T.anchor_pull(flat, torch.tensor([0.9, 0.9, 0.9]), strength=1.0)
ck("per-hop cap is honoured",
float((o2[-1] - flat[-1]).mean()) <= T.ANCHOR_MAX_SHIFT + 0.02,
"cap %.2f" % T.ANCHOR_MAX_SHIFT)
same = (torch.rand(N, HW, HW, 3) * 0.1 + 0.5).clamp(0, 1)
o3, n3 = T.anchor_pull(same, T.anchor_stats(same))
ck("no-op when already on the anchor", torch.equal(o3, same) and not n3)
a, _ = T.compensate(same, flat, "anchor", OV)
b, _ = T.compensate(same, flat, "frame_shift", OV)
ck("compensate(anchor) == compensate(frame_shift)", torch.allclose(a, b),
"the chain-wide half lives in anchor_pull")
def chain(mode):
# Same seed for every mode, so the two runs differ ONLY by the mode.
# Without this the comparison is against different noise and the seam
# numbers wander by more than the effect being measured.
torch.manual_seed(7)
prev = ref = None
means, seams = [], []
for i in range(8):
start = float(prev[-1].mean()) if prev is not None else 0.55
imgs = (torch.rand(N, HW, HW, 3) * 0.05 + start
+ torch.linspace(0.0, -0.045, N).view(-1, 1, 1, 1)).clamp(0, 1)
if mode != "off" and prev is not None:
imgs, _ = T.compensate(prev, imgs, "frame_shift", OV)
if mode == "anchor" and ref is not None:
imgs, _ = T.anchor_pull(imgs, ref)
if i == 0:
ref = T.anchor_stats(imgs)
if prev is not None:
seams.append(abs(float(imgs[0].mean() - prev[-1].mean())))
means.append(float(imgs.mean()))
prev = imgs[-OV:].clone()
return (means[0] - means[-1]) * 255.0, max(seams) * 255.0
slide_fs, seam_fs = chain("frame_shift")
slide_an, seam_an = chain("anchor")
ck("anchor cuts the 8-hop slide by more than half",
slide_an < slide_fs * 0.5,
"frame_shift %+.1f/255 -> anchor %+.1f/255" % (slide_fs, slide_an))
ck("anchor does not regress the seam", seam_an <= seam_fs + 0.02,
"anchor %.3f vs frame_shift %.3f /255" % (seam_an, seam_fs))
# ------------------------------------------------------- over-delivery
print("\nplan.check_over_delivery")
def plan_of(*shots):
return P.parse_plan(json.dumps({"shots": list(shots)}))
fires = [
("settle + 'continues'", plan_of(
{"beat": "She sits.", "directives": {"tail": "settle"}},
{"beat": "She continues the story."})),
("hold + gerund opening", plan_of(
{"beat": "Quiet.", "directives": {"tail": "hold"}},
{"beat": "Walking to the window, she looks out."})),
]
for name, sh in fires:
ck("fires: " + name, len(P.check_over_delivery(sh)) == 1)
quiet = [
("default tail", plan_of({"beat": "She sits."},
{"beat": "She continues."})),
("settle + standstill opening", plan_of(
{"beat": "She sits.", "directives": {"tail": "settle"}},
{"beat": "After a moment she looks up."})),
("stillness / keepsake / morning are not hits", plan_of(
{"beat": "She sits.", "directives": {"tail": "settle"}},
{"beat": "Morning light fills the stillness; a keepsake sits there."})),
]
for name, sh in quiet:
ck("quiet: " + name, not P.check_over_delivery(sh))
for fn in ("HandTieClips_Starter.json", "HandTieClips_Showcase.json"):
wf = json.load(io.open(os.path.join(HERE, "workflows", fn), encoding="utf-8"))
node = next(n for n in wf["nodes"] if n["type"] == "HandTieClips")
sh = P.parse_plan(node["widgets_values"][17])
ck("shipped plan stays quiet: " + fn, not P.check_over_delivery(sh))
ck("tone field accepts free/rebase",
[x["tone"] for x in plan_of({"beat": "a", "tone": "free"},
{"beat": "b", "tone": "rebase"})]
== ["free", "rebase"])
try:
plan_of({"beat": "a", "tone": "nonsense"})
ck("tone field rejects garbage", False)
except ValueError:
ck("tone field rejects garbage", True)
# ---------------------------------------------------------------- sheet
print("\nsheet")
rows = [{"hop": 1, "first": torch.rand(64, 114, 3), "last": torch.rand(64, 114, 3),
"beat": "A beat.", "directives": {"join": "continuous"},
"meta": ["362f"], "note": "tone: anchor"}]
ck("builds with frames", SH.build(rows, "t").shape[1] > 50)
ck("builds text-only (dry run shape)",
SH.build([{"hop": 1, "first": None, "last": None, "beat": "x",
"directives": {}}], "t").shape[1] > 20)
ck("empty -> placeholder", encodable(SH.build([])))
ck("hostile row -> placeholder, no raise",
encodable(SH.build([{"hop": 1, "first": "nope", "beat": "x"}])))
ck("placeholder floors at 2x2, never 1x1",
encodable(SH.placeholder(1, 1)) and encodable(SH.placeholder(0, 0)))
ck("placeholder rounds odd dimensions down to even",
tuple(SH.placeholder(737, 415).shape) == (1, 414, 736, 3))
ck("small() shrinks a full frame",
tuple(SH.small(torch.rand(736, 1280, 3)).shape)[0] == SH.THUMB_H)
# ----------------------------------------------------------- seam node
print("\nseam report")
total = 362 + 340 * 2
v = torch.full((total, 16, 16, 3), 0.50)
v[702:] -= 0.02
rows_, hop_len, _ = S.measure(v, 3, 22, 6)
ck("derives whole-frame hop length", abs(hop_len - 362.0) < 0.01, "%.2f" % hop_len)
ck("finds the seams at the right frames",
[r["at"] for r in rows_] == [362, 702], str([r["at"] for r in rows_]))
ck("reads the planted -5.1/255 step",
abs(rows_[1]["luma"] + 5.1) < 0.2 and rows_[1]["verdict"] == "VISIBLE",
"%+.2f/255 %s" % (rows_[1]["luma"], rows_[1]["verdict"]))
ck("calls the clean seam invisible", rows_[0]["verdict"] == "invisible")
ck("chart renders", S.chart(rows_).shape[1] > 50)
# ------------------------------------------------------------- dry run
print("\ndry_run / render_through / quality")
class Trap:
def __getattr__(self, k):
raise AssertionError("dry run reached the sampler (%s)" % k)
for name in ("MiniMaxH3ReferenceToVideo", "SamplerCustomAdvanced",
"MiniMaxH3SigmaShift", "BasicScheduler", "KSamplerSelect"):
setattr(H3, name, Trap())
H3._model_fingerprint = lambda *a, **k: (_ for _ in ()).throw(
AssertionError("dry run fingerprinted the model"))
H3._push_preview = lambda *a, **k: None
plan8 = {"shots": [{"beat": "Shot %d happens." % (i + 1),
"directives": {"join": "continuous"} if i else {}}
for i in range(8)]}
def run(**kw):
base = dict(model=object(), clip=object(), vae=object(), audio_vae=object(),
prompt="unused", chains="8", resolution="1.0 MP",
aspect="16:9 landscape", duration="15 s", overlap="0.9 s",
seed=41, seed_per_shot=True, steps=14,
sampler_name="res_multistep", scheduler="beta",
shift_video=12.0, shift_audio=3.0, ref_image_size="match",
shot_plan=json.dumps(plan8), dry_run="on", unique_id="t")
base.update(kw)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
out = H3.HandTieClips().run(**base)
return out, buf.getvalue()
out, _ = run(tone_compensate="anchor", cache_hops="on")
ck("dry run returns four values", len(out) == 4)
ck("dry run never reached the sampler", True, "asserted by the traps above")
# Was `== (1, 1, 1, 3)`. A 1x1 frame is inert to SaveImage and fatal to
# every video encoder -- libx264 cannot open a yuv420p context on an odd
# dimension -- so the Starter's own SaveVideo died on a dry run. The
# contract is now "encodable, at the geometry the plan resolved to".
ck("dry-run images is one encodable frame", encodable(out[0]))
ck("dry-run images carries the planned geometry",
tuple(out[0].shape) == (1, 736, 1280, 3), str(tuple(out[0].shape)))
ck("audio is silent, not None", out[1]["waveform"].abs().max() == 0)
ck("info carries every compiled prompt", out[2].count("===== hop") == 8)
ck("contact sheet is built on a dry run", out[3].shape[1] > 100)
for rt, want in ((0, 8), (3, 3), (8, 8), (12, 8)):
o, _ = run(render_through=rt)
ck("render_through=%d compiles %d hop(s)" % (rt, want),
o[2].count("===== hop") == want)
_, log = run(quality="draft", render_through=2)
ck("draft drops the canvas", "736x416" in log and "0.3 MP" in log)
ck("draft drops the steps", "6 steps" in log)
_, log = run(quality="final", render_through=2)
ck("final leaves the canvas alone", "1280x736" in log)
print()
if FAIL:
print("%d FAILURE(S): %s" % (len(FAIL), ", ".join(FAIL)))
return 1
print("ALL PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|