File size: 19,679 Bytes
bc50ea0 | 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 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 | # Adapted from NikoDemon80/ComfyUI-H3-Motion-Context 0.3.0.
"""Seam probe node: measure a join without leaving the graph.
seam_probe.py and level_step.py answer the two questions that matter at a
join, but both work on rendered files and both have to infer where the
seam is. That inference is the source of the constant ~8ms lag the CLI
probe reports: anchoring on a file's end rather than on the frame the
picture actually ends at is off by exactly the audio grid overhang, which
is a third of a step.
This node does not have to infer anything. It takes clip A as the same
latent you already pin from, and clip B before the trim, so the seam is
at a known sample and the pinned span is given rather than guessed. It
decodes A's audio and applies match_tail itself, which matters: comparing
against an untrimmed clip A would move the anchor by the overhang and
hand back a very convincing lag that is purely an artifact. Doing it
inside the node means that cannot be got wrong from the canvas.
Wire it inline: the audio VAE decode feeds this node, and this node feeds
the trim. The audio output is the input unchanged, so dropping it into an
existing chain changes nothing about the render.
clip_a_latent the same latent wired into Motion Context's
context_latent
audio_vae the same audio VAE
clip_b_untrimmed this clip's audio off the VAE decode, before trim
trim_frames from Motion Context, the same value the trim gets
Reported per join:
lag_ms how far B's reconstruction of the pinned span runs behind
A's original. Positive means B is late.
corr normalised cross-correlation over the pinned span. 1.0 is
identical shape, ~0 is unrelated. Low corr means the model
is imitating rather than continuing.
broadband RMS step across the cut, normalised to [0, 1].
floor the same for the 10th-percentile short-window RMS, which
tracks the bed under the content. This is the one that
catches a clip inventing its own silence.
Thresholds in the summary are the ones documented in the two scripts, not
new judgments. Numbers are always reported; the labels are a reading aid.
One limitation to know before trusting a lag figure. Cross-correlation
cannot tell a true alignment from one a whole cycle away, so on strongly
periodic content a lag larger than half a period reads as the nearest
alias instead: a 60 ms offset on material with an 18 ms period comes back
as -12.7 ms, confidently and at high correlation. Beat-driven music is
exactly this case. The tracker follows continuity between windows so a
drift cannot cycle-hop mid-span, and the first window takes the global
peak rather than assuming zero, which is what lets a uniform whole-step
offset be seen at all. But a large uniform offset on periodic content is
not something the method can resolve. Read a small lag as reliable and a
large one as "at least this much, possibly plus a whole number of
cycles".
"""
import logging
import numpy as np
from .nodes import _pixel_frames, _streams_from_latent
_LOG = logging.getLogger("h3_motion_context")
# from seam_probe.py: a match below this is not credible enough to track
CORR_CREDIBLE = 0.6
# from level_step.py
FLOOR_CLEAN, FLOOR_MARGINAL = 0.25, 0.50
BROADBAND_OK = 0.40
def _mono(waveform):
"""Any [.., C, L] or [.., L, C] audio tensor -> mono float64 numpy.
The audio VAE's decode output orientation is not something to assume,
so the channel axis is identified by being the short one rather than
by position. Audio is thousands of samples long and at most a handful
of channels wide, so this is unambiguous in practice.
"""
a = waveform
if hasattr(a, "detach"):
a = a.detach()
if hasattr(a, "cpu"):
a = a.cpu()
a = np.asarray(a, dtype=np.float64)
while a.ndim > 2:
a = a[0]
if a.ndim == 2:
axis = 0 if a.shape[0] <= a.shape[1] else 1
a = a.mean(axis=axis)
return a
def _norm_xcorr(win, ref):
"""Slide `win` across `ref`; return normalised correlation curve."""
win = win - win.mean()
ref = ref - ref.mean()
wn = np.sqrt((win ** 2).sum())
if wn < 1e-12:
return None
corr = np.correlate(ref, win, mode="valid")
csum = np.concatenate(([0.0], np.cumsum(ref ** 2)))
seg = csum[len(win):] - csum[:-len(win)]
denom = wn * np.sqrt(np.maximum(seg, 1e-12))
return corr / denom
def _tracked_peak(ncc, expect_idx):
"""Pick the alignment closest to the previous window's, among local
maxima within 10% of the best. Music is periodic, so the global argmax
can hop a whole cycle between windows and read as drift."""
best = float(ncc.max())
i = np.arange(1, len(ncc) - 1)
peaks = i[(ncc[i] >= ncc[i - 1]) & (ncc[i] >= ncc[i + 1]) &
(ncc[i] >= 0.9 * best)]
if len(peaks) == 0:
peaks = np.array([int(np.argmax(ncc))])
pick = int(peaks[np.argmin(np.abs(peaks - expect_idx))])
return pick, float(ncc[pick])
def _rms(x):
if len(x) == 0:
return 0.0
return float(np.sqrt(np.mean(np.square(x))))
def _floor_level(x, sr, win_ms=20.0, pct=10.0):
"""A low percentile of short-window RMS: the bed under the content."""
win = max(1, int(round(win_ms / 1000.0 * sr)))
n = len(x) // win
if n < 3:
return _rms(x)
trimmed = x[:n * win].reshape(n, win)
levels = np.sqrt(np.mean(np.square(trimmed), axis=1))
return float(np.percentile(levels, pct))
def _step_ratio(a, b):
denom = a + b
if denom <= 1e-12:
return 0.0
return abs(a - b) / denom
def _db(a, b):
if a <= 1e-12 or b <= 1e-12:
return float("inf")
return abs(20.0 * np.log10(a / b))
class MiniMaxH3ContexLoopSeamProbe:
"""Measure a chain join: continuation quality and level step."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"clip_b_untrimmed": ("AUDIO", {
"tooltip": "This clip's audio straight off the VAE "
"decode, BEFORE the trim node. It still "
"carries the pinned head, which is what "
"gets compared."}),
"trim_frames": ("INT", {
"default": 0, "min": 0, "max": 4096,
"tooltip": "Wire this from the Motion Context node's "
"trim output, the same value the trim node "
"gets. It is the pinned span."}),
},
"optional": {
"clip_a_latent": ("LATENT", {
"tooltip": "The PREVIOUS clip's AV latent: the same "
"one wired into Motion Context's "
"context_latent. The node decodes and "
"tail-matches it itself. Without it only "
"clip B is described, nothing is "
"measured."}),
"audio_vae": ("VAE", {
"tooltip": "The H3 audio VAE, needed to decode clip "
"A's audio out of its latent."}),
"fps": ("FLOAT", {
"default": 24.0, "min": 1.0, "max": 240.0,
"step": 0.001,
"tooltip": "Must match what you feed Create Video."}),
"window_ms": ("FLOAT", {
"default": 50.0, "min": 5.0, "max": 500.0,
"step": 1.0,
"tooltip": "Correlation analysis window."}),
"search_ms": ("FLOAT", {
"default": 40.0, "min": 5.0, "max": 500.0,
"step": 1.0,
"tooltip": "Maximum lag searched either side."}),
},
}
RETURN_TYPES = ("AUDIO", "STRING")
RETURN_NAMES = ("audio", "report")
OUTPUT_TOOLTIPS = (
"clip_b_untrimmed, unchanged. Wire it on to the trim node.",
"The measurement report, for a Preview Text node.",
)
FUNCTION = "probe"
CATEGORY = "conditioning/minimax/context_loop"
OUTPUT_NODE = True
DESCRIPTION = ("Measure a chain join in the graph: does clip B "
"continue clip A's audio, and does the level step at "
"the cut.")
def probe(self, clip_b_untrimmed, trim_frames, clip_a_latent=None,
audio_vae=None, fps=24.0, window_ms=50.0, search_ms=40.0):
passthrough = clip_b_untrimmed
sr = int(clip_b_untrimmed["sample_rate"])
b = _mono(clip_b_untrimmed["waveform"])
span_s = max(0, int(trim_frames)) / float(fps)
span = int(round(span_s * sr))
lines = ["H3 seam probe",
"clip B (untrimmed): %.4fs at %d Hz" % (len(b) / sr, sr),
"pinned span: %d frames = %.4fs = %d samples"
% (int(trim_frames), span_s, span)]
if span <= 0:
lines += ["", "trim_frames is 0, so there is no pinned span "
"and nothing to measure. Wire it from the "
"Motion Context node."]
return self._finish(passthrough, lines)
if len(b) < span:
lines += ["", "clip B is shorter than the pinned span. This "
"is the TRIMMED audio; wire the VAE decode "
"output instead."]
return self._finish(passthrough, lines)
if clip_a_latent is None or audio_vae is None:
missing = "clip_a_latent" if clip_a_latent is None else "audio_vae"
lines += ["", "no %s wired: measuring the join needs the "
"previous clip's latent and the audio VAE to "
"decode it." % missing]
return self._finish(passthrough, lines)
try:
a, raw, frames = self._clip_a_audio(clip_a_latent, audio_vae,
sr, fps)
except ValueError as exc:
lines += ["", str(exc)]
return self._finish(passthrough, lines)
pad = len(a) - len(raw)
lines.append("clip A: %d frames, decoded %.4fs, tail-matched to "
"%.4fs (%s %d samples)"
% (frames, len(raw) / sr, len(a) / sr,
"padded" if pad > 0 else "trimmed" if pad < 0
else "exact", abs(pad)))
lines.append("")
if len(a) < span:
lines.append("clip A is shorter than the pinned span, so "
"there is nothing to compare against.")
return self._finish(passthrough, lines)
lines += self._continuation(a, b, sr, span, window_ms, search_ms)
if pad > 0 and len(raw) >= span:
# Padding lengthened the array without moving the content, so
# the two anchors sit `pad` samples apart and one of them is
# wrong. Measuring both says which: a lag that vanishes
# against the raw end is an anchoring artifact, one that
# survives both is in the render.
alt = self._continuation(raw, b, sr, span, window_ms,
search_ms)
lines += ["", "cross-check: same audio anchored on the raw "
"decode instead of the padded end"]
lines += [" " + ln for ln in alt[1:]]
lines.append(" the padding is %.2f ms, so these two should "
"differ by that much. Whichever reads near zero "
"is the true alignment."
% (pad / sr * 1000.0,))
lines.append("")
lines += self._level(a, b, sr, span)
return self._finish(passthrough, lines)
@staticmethod
def _clip_a_audio(latent, audio_vae, sr, fps):
"""Decode clip A's audio and return (tail-matched, raw, frames).
The tail is the only part that gets read, and its position is
what the whole measurement is anchored on, so it has to sit
exactly where the delivered clip's does. H3 rounds its audio grid
to the nearest step, so a raw decode is up to a third of a step
long or short; leaving that in would shift every reported lag by
the same amount and look exactly like a real finding. The head
trim is not applied because it does not move the tail.
The raw decode comes back too, because tail-matching a SHORT clip
pads it, and padding moves the end of the array without moving
the content. On those clips the two arrays disagree about where
the anchor is by exactly the overhang, so the probe measures
against both and reports the difference rather than picking one
and hoping.
"""
parts = _streams_from_latent(latent)
if len(parts) < 2:
raise ValueError(
"clip_a_latent has no audio stream. Wire the sampler "
"output of an H3 AV graph, not a video-only latent.")
video, audio = parts[0], parts[1]
if video.ndim == 4:
video = video.unsqueeze(0)
frames = _pixel_frames(int(video.shape[2]))
if audio.ndim == 3:
audio = audio.unsqueeze(0)
decoded = audio_vae.decode(audio[:1])
a = _mono(decoded)
vae_sr = int(getattr(audio_vae, "audio_sample_rate", 32000))
if vae_sr != sr:
raise ValueError(
"clip A decodes at %d Hz but clip B is %d Hz. Not "
"comparable." % (vae_sr, sr))
want = int(round(frames / float(fps) * sr))
have = len(a)
raw = a
if have > want:
a = a[:want]
elif have < want:
a = np.concatenate([a, np.zeros(want - have)])
return a, raw, frames
def _continuation(self, a, b, sr, span, window_ms, search_ms):
"""B's first `span` samples should reconstruct A's last `span`."""
win = max(1, int(round(window_ms / 1000.0 * sr)))
hop = max(1, int(round(0.025 * sr))) # one audio latent step
search = max(1, int(round(search_ms / 1000.0 * sr)))
a_tail_start = len(a) - span
lags, corrs = [], []
t, prev_lag = 0, None
while t + win <= span:
centre = a_tail_start + t
lo = max(0, centre - search)
hi = min(len(a), centre + win + search)
ref = a[lo:hi]
if len(ref) <= win:
break
ncc = _norm_xcorr(b[t:t + win], ref)
if ncc is None:
t += hop
continue
# positive lag = B is LATE, matching an earlier part of A
if prev_lag is None:
# Nothing to track from yet, so take the global peak. Not
# zero: assuming the first window is aligned makes a join
# that is uniformly offset by a whole audio step read as
# the nearest cycle alias instead, which for periodic
# content can be a fraction of the real number. That is
# exactly the failure this probe exists to find.
pick = int(np.argmax(ncc))
corr = float(ncc[pick])
else:
expect = (centre - prev_lag) - lo
pick, corr = _tracked_peak(ncc, expect)
lag = centre - (lo + pick)
lags.append(lag / sr * 1000.0)
corrs.append(corr)
if corr > CORR_CREDIBLE:
prev_lag = lag
t += hop
out = ["continuation over the pinned span"]
if not corrs:
out.append(" window longer than the span; nothing analysed")
return out
corrs = np.array(corrs)
lags = np.array(lags)
strong = corrs > CORR_CREDIBLE
out.append(" mean corr %.3f (%d/%d windows above %.1f)"
% (corrs.mean(), int(strong.sum()), len(corrs),
CORR_CREDIBLE))
if strong.any():
sl = lags[strong]
out.append(" lag %+.2f ms mean, %+.2f to %+.2f "
"(credible windows only)"
% (sl.mean(), sl.min(), sl.max()))
at_edge = np.abs(sl) >= (search / sr * 1000.0 - 1.0)
if at_edge.any():
out.append(" %d window(s) pegged at the search limit; the "
"real lag is outside it. Raise search_ms."
% int(at_edge.sum()))
else:
out.append(" lag not reported: no window correlated "
"well enough to trust an alignment")
if corrs.mean() < 0.3:
out.append(" reading: the pinned audio is not being continued "
"at all. Check that context_latent or "
"context_audio is actually wired.")
elif corrs.mean() < CORR_CREDIBLE:
out.append(" reading: weak. The model is imitating the tail "
"rather than continuing it.")
elif strong.any() and abs(lags[strong].mean()) > 12.0:
out.append(" reading: locked but offset by more than half an "
"audio step. Placement, not continuation.")
else:
out.append(" reading: clean continuation.")
return out
def _level(self, a, b, sr, span):
"""The delivered join: A's tail against B's first post-trim audio."""
b_delivered = b[span:]
n = min(int(0.5 * sr), len(a), len(b_delivered))
out = ["level step across the delivered cut"]
if n < int(0.05 * sr):
out.append(" too little audio either side of the cut to "
"measure")
return out
left, right = a[-n:], b_delivered[:n]
bb = _step_ratio(_rms(left), _rms(right))
fl = _step_ratio(_floor_level(left, sr), _floor_level(right, sr))
out.append(" broadband %.3f (%.1f dB) %s"
% (bb, _db(_rms(left), _rms(right)),
"ok" if bb < BROADBAND_OK else "high"))
out.append(" floor %.3f (%.1f dB) %s"
% (fl, _db(_floor_level(left, sr),
_floor_level(right, sr)),
"clean" if fl < FLOOR_CLEAN
else "marginal" if fl < FLOOR_MARGINAL
else "audible step"))
out.append(" measured over %.0f ms either side" % (n / sr * 1000.0,))
if fl >= FLOOR_MARGINAL and bb < BROADBAND_OK:
out.append(" reading: the music matches but the bed underneath "
"restarts at the cut. Wire the previous clip's "
"audio into Motion Context.")
out.append(" note: the floor figure reads the quiet moments "
"between content, so wall to wall music with no rests "
"gives it nothing to measure and it collapses onto the "
"broadband number.")
return out
@staticmethod
def _finish(passthrough, lines):
report = "\n".join(lines)
for line in lines:
_LOG.info("h3_motion_context: %s", line)
return {"ui": {"text": [report]},
"result": (passthrough, report)}
NODE_CLASS_MAPPINGS = {
"MiniMaxH3ContexLoopSeamProbe": MiniMaxH3ContexLoopSeamProbe,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MiniMaxH3ContexLoopSeamProbe": "MiniMax H3 Context Loop Seam Probe",
}
|