File size: 5,867 Bytes
7c2113f 264846c 7c2113f 264846c 7c2113f 264846c 7c2113f | 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 | """H3 Chain Preview -- the chain's progress panel, as its own node.
Split off `HandTieClips` deliberately. That node already carries a 21-widget shot
editor and a reference rail; folding a large media panel onto it as well makes
an unreadable node, and the panel wants to be somewhere else in the graph
anyway -- on the IMAGE wire, right before CreateVideo, where the thing being
previewed actually flows.
It is a passthrough: images and audio go straight out again, unchanged, so
dropping it into an existing chain costs nothing and removing it changes no
pixels. What it adds is a place for the live `h3_refchain_preview` events to
land, and an end-of-run report of the two numbers the chain never surfaced --
which pin mechanism each hop actually used, and how far the audio has drifted
from the video.
"""
from __future__ import annotations
try:
from server import PromptServer
except Exception: # noqa: BLE001 -- headless / API-only runs have no server
PromptServer = None
TAG = "HTCChainPreview"
FPS = 24
def _send(payload):
if PromptServer is None:
return
try:
PromptServer.instance.send_sync(
"h3_chain_preview", payload, PromptServer.instance.client_id)
except Exception as e: # noqa: BLE001
print(f"[{TAG}] preview send skipped: {e!r}", flush=True)
class HTCChainPreview:
"""Pass images and audio through, and report the join.
Wire between HandTieClips and CreateVideo.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE", {"tooltip": "From H3 Ref2VA Chain. Passed straight through."}),
},
"optional": {
"audio": ("AUDIO", {
"tooltip": (
"The chain's audio. Wire it and the panel reports A/V "
"drift, which accumulates roughly 40 ms per hop from the "
"audio crossfade at each join."
),
}),
"info": ("STRING", {
"forceInput": True,
"tooltip": "The chain's `info` output. Shown in the panel's detail view, never in the status strip.",
}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = ("IMAGE", "AUDIO")
RETURN_NAMES = ("images", "audio")
FUNCTION = "run"
CATEGORY = "Hand Tie Clips"
OUTPUT_NODE = False
DESCRIPTION = (
"Preview panel for an H3 Ref2VA chain. Sits on the IMAGE wire before "
"CreateVideo and passes images and audio through untouched. Shows the "
"live sample, per-hop progress, which pin mechanism each hop used "
"(Motion-Context vs the AddGuide fallback), and end-of-run A/V drift."
)
def run(self, images, audio=None, info="", unique_id=None):
frames = int(images.shape[0]) if images is not None else 0
video_s = frames / float(FPS)
# A dry run compiles prompts and hands back ONE placeholder frame at the
# resolution the plan resolved to. Reporting A/V drift from that is
# reporting a number about nothing: the run printed "1f / 0.04s video,
# 0.02s audio, drift -18 ms", which reads exactly like a measurement of
# a real chain and describes a frame that was never rendered.
#
# `info` is how it is known, rather than a frame count: the chain writes
# its DRY RUN banner as the first line, so this is what actually
# happened rather than a guess from the shape of the tensor.
dry = str(info or "").lstrip().startswith("DRY RUN")
payload = {
"node_id": unique_id,
"frames": frames,
"video_s": round(video_s, 3),
"width": int(images.shape[2]) if frames else 0,
"height": int(images.shape[1]) if frames else 0,
"dry_run": dry,
}
if dry:
print(f"[{TAG}] dry run: prompts compiled, nothing rendered -- "
f"no drift to measure. The placeholder is "
f"{payload['width']}x{payload['height']}, the size the plan "
f"resolved to.", flush=True)
elif isinstance(audio, dict) and audio.get("waveform") is not None:
wav = audio["waveform"]
sr = int(audio.get("sample_rate") or 0)
if sr:
audio_s = float(wav.shape[-1]) / float(sr)
drift_ms = (audio_s - video_s) * 1000.0
payload["audio_s"] = round(audio_s, 3)
payload["sample_rate"] = sr
payload["drift_ms"] = round(drift_ms, 1)
# Worth a console line too: it is cumulative across a chain and
# nothing else in the pack reports it.
print(f"[{TAG}] {frames}f / {video_s:.2f}s video, "
f"{audio_s:.2f}s audio, drift {drift_ms:+.0f} ms", flush=True)
if info:
payload["info"] = str(info)
_send(payload)
return (images, audio)
# -- pre-rename ids ----------------------------------------------------------
# A plain alias in NODE_CLASS_MAPPINGS keeps old workflows loading, but it also
# lists the node a second time in search: ComfyUI falls back to the mapping key
# when NODE_DISPLAY_NAME_MAPPINGS has no entry. Subclassing and setting
# DEPRECATED gets both -- server.py publishes `deprecated: True`, and the
# frontend's `Comfy.Node.ShowDeprecated` (off by default) hides it from search
# while leaving it fully functional in workflows that name it.
class _LegacyH3ChainPreview(HTCChainPreview):
DEPRECATED = True
NODE_CLASS_MAPPINGS = {
"HTCChainPreview": HTCChainPreview,
"H3ChainPreview": _LegacyH3ChainPreview,
}
NODE_DISPLAY_NAME_MAPPINGS = {"HTCChainPreview": "H3 Chain Preview"}
|