File size: 4,875 Bytes
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
"""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)
        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,
        }

        if 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"}