trim windows on every media input, and a per-reference pixel budget
Browse filesUsing the soundtrack for the first time exposed the gap: a 173 s track under a
9.4 s chain, and `loop` silently takes the first 9.4 s -- which for a mastered
track is the intro. There was no way to say WHICH 9.4 seconds.
The same hole was on the other two inputs, and on one of them it is not a
convenience issue. MiniMaxH3ReferenceToVideo hands the whole voice file to
`_encode_ref_audio` with no cap, and every latent frame that produces is a
token the DiT attends over on every step of every hop. An untrimmed
three-minute voice reference is a large, silent, permanent tax on the render.
The reference clip is truncated to the hop length, but only from frame 0, so
you could not point at the motion you wanted.
Six FLOAT widgets, appended last: voice / reference_video / music, start+end.
An end of 0.0 means "to the end of the file", so the default pair is untrimmed
and a longer replacement file still plays out. `media.clip_window` is the ONE
definition of what a window means -- four readers need to agree, and four
copies of that arithmetic would eventually disagree by a rounding rule. It can
never return an empty span: reversed, negative, past-the-end and
shorter-than-50 ms all fall back to the whole file, because a trim that did not
take is a puzzle while an empty tensor is a crash deep inside the model.
waveform.py computes peaks HERE, not in the browser. The first design decoded
in the tab, which is ~66 MB of Float32 for this track, per control, to draw a
picture 240 pixels wide. PromptMasterLD has four trim controls and not one
decodeAudioData between them; it is right. Buckets are max, not mean -- a mean
flattens transients into a sausage, and transients are the only landmarks you
can trim against. `seconds` comes from the decoded sample count because MP3
Xing/LAME headers routinely report double, and a duration that lies makes every
position on the bar lie with it. Measured: 173.49 s, matching the decode.
The route runs the decode in an executor. These handlers share ComfyUI's event
loop and this file takes 1.68 s to decode -- on the loop that is the canvas,
the queue and the progress bar all stopping together. Cached on (name, mtime,
n); mtime for the same reason media.stamp has it.
Per-reference `mp` lands as a ref_plan field, floor 0.3, absent/0 = no cap.
Down only, edges on H3's 16 px grid, and it says so in the console. Framed as
what it is: a token dial, so a location plate need not cost what a face costs.
Deliberately NOT offered on the first frame -- AddGuide does
`_resize(image, width, height, "center")`, so whatever you feed it becomes
exactly the canvas and the control would be wired to nothing.
The hop cache needed no change. chain_salt digests the loaded tensors, not the
filenames, so a trimmed voice is already a different key.
check_waveform.py: 38 assertions, green first run. Workflows 39 -> 45.
Still to come: the trim bar UI itself, and the rail control for mp. Until then
both are reachable through the ref_plan JSON and the raw widgets.
- h3_ref_chain.py +73 -5
- media.py +114 -6
- refs.py +28 -1
- routes.py +68 -0
- tools/check_waveform.py +122 -0
- waveform.py +106 -0
- workflows/HandTieClips_Showcase.json +7 -1
- workflows/HandTieClips_Starter.json +7 -1
|
@@ -1335,6 +1335,65 @@ class HandTieClips:
|
|
| 1335 |
"are set."
|
| 1336 |
),
|
| 1337 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1338 |
},
|
| 1339 |
"hidden": {
|
| 1340 |
"unique_id": "UNIQUE_ID",
|
|
@@ -1396,6 +1455,9 @@ class HandTieClips:
|
|
| 1396 |
contact_sheet="off", tone_anchor=_tone.ANCHOR_STRENGTH,
|
| 1397 |
soundtrack=None, music_gain_db=-14.0, music_duck=0.6,
|
| 1398 |
music_fit="loop", music_fade_s=1.0, soundtrack_file="",
|
|
|
|
|
|
|
|
|
|
| 1399 |
unique_id=None):
|
| 1400 |
dry = str(dry_run) == "on"
|
| 1401 |
draft = str(quality) == "draft"
|
|
@@ -1496,9 +1558,13 @@ class HandTieClips:
|
|
| 1496 |
# before anything expensive starts. Each returns exactly what the socket
|
| 1497 |
# it replaced delivered, so everything downstream is unchanged.
|
| 1498 |
start_image = _media.load_image(start_image_file) if start_image_file else None
|
| 1499 |
-
reference_video = (_media.load_video(
|
| 1500 |
-
|
| 1501 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1502 |
for _name, _got in (("start_image", start_image_file and start_image is None),
|
| 1503 |
("reference_video", reference_video_file and reference_video is None),
|
| 1504 |
("voice", voice_file and voice is None),
|
|
@@ -1519,7 +1585,7 @@ class HandTieClips:
|
|
| 1519 |
for _r in ref_plan_refs:
|
| 1520 |
if not _r["file"]:
|
| 1521 |
continue
|
| 1522 |
-
_im = _media.load_image(_r["file"])
|
| 1523 |
if _im is not None and _im.shape[0] > 0:
|
| 1524 |
slot_images[_r["slot"]] = _im[:1]
|
| 1525 |
if ref_plan_refs:
|
|
@@ -2152,7 +2218,9 @@ class HandTieClips:
|
|
| 2152 |
# not, and silently preferring the stale one would be the worse guess.
|
| 2153 |
_bed = soundtrack
|
| 2154 |
if _bed is None and soundtrack_file:
|
| 2155 |
-
_bed = _media.load_audio(soundtrack_file
|
|
|
|
|
|
|
| 2156 |
elif _bed is not None and soundtrack_file:
|
| 2157 |
print(f"[{TAG}] soundtrack: using the wired socket, not "
|
| 2158 |
f"{soundtrack_file!r}", flush=True)
|
|
|
|
| 1335 |
"are set."
|
| 1336 |
),
|
| 1337 |
}),
|
| 1338 |
+
# Trim windows, appended 2026-09-01 -- LAST, per the note at
|
| 1339 |
+
# the top of this block. Six floats rather than one JSON blob
|
| 1340 |
+
# because these three slots are fixed and named, the same
|
| 1341 |
+
# reason `voice_file` is its own widget. The rail's references
|
| 1342 |
+
# are a LIST, which is why their per-item settings live in
|
| 1343 |
+
# `ref_plan` instead.
|
| 1344 |
+
#
|
| 1345 |
+
# An end of 0.0 is the sentinel for "to the end of the file",
|
| 1346 |
+
# so the default pair (0, 0) is untrimmed and costs nothing.
|
| 1347 |
+
# media.clip_window is the single definition of what they mean.
|
| 1348 |
+
"voice_start_s": ("FLOAT", {
|
| 1349 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1350 |
+
"tooltip": (
|
| 1351 |
+
"Trim window into the voice reference, in seconds. Leave both at 0 for "
|
| 1352 |
+
"the whole file; an end of 0 always means "
|
| 1353 |
+
"'to the end', so a longer replacement file "
|
| 1354 |
+
"still plays out. "
|
| 1355 |
+
"Worth setting: H3 encodes the WHOLE voice file into the "
|
| 1356 |
+
"conditioning with no cap, and every latent frame of it "
|
| 1357 |
+
"is attended over on every step of every hop. A "
|
| 1358 |
+
"three-minute take is a large invisible tax."
|
| 1359 |
+
),
|
| 1360 |
+
}),
|
| 1361 |
+
"voice_end_s": ("FLOAT", {
|
| 1362 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1363 |
+
"tooltip": "End of the voice window. 0 = to the end of the file.",
|
| 1364 |
+
}),
|
| 1365 |
+
"reference_video_start_s": ("FLOAT", {
|
| 1366 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1367 |
+
"tooltip": (
|
| 1368 |
+
"Trim window into the reference clip, in seconds. Leave both at 0 for "
|
| 1369 |
+
"the whole file; an end of 0 always means "
|
| 1370 |
+
"'to the end', so a longer replacement file "
|
| 1371 |
+
"still plays out. "
|
| 1372 |
+
"H3 already truncates the clip to the hop length, but "
|
| 1373 |
+
"only from frame 0 -- so without this there is no way to "
|
| 1374 |
+
"point at the motion you actually want."
|
| 1375 |
+
),
|
| 1376 |
+
}),
|
| 1377 |
+
"reference_video_end_s": ("FLOAT", {
|
| 1378 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1379 |
+
"tooltip": "End of the reference clip window. 0 = to the end.",
|
| 1380 |
+
}),
|
| 1381 |
+
"music_start_s": ("FLOAT", {
|
| 1382 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1383 |
+
"tooltip": (
|
| 1384 |
+
"Trim window into the soundtrack, in seconds. Leave both at 0 for "
|
| 1385 |
+
"the whole file; an end of 0 always means "
|
| 1386 |
+
"'to the end', so a longer replacement file "
|
| 1387 |
+
"still plays out. "
|
| 1388 |
+
"The window is cut from the TRACK first; music_fit then "
|
| 1389 |
+
"loops or trims that to the chain. Without it a mastered "
|
| 1390 |
+
"track always starts the chain on its intro."
|
| 1391 |
+
),
|
| 1392 |
+
}),
|
| 1393 |
+
"music_end_s": ("FLOAT", {
|
| 1394 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1395 |
+
"tooltip": "End of the soundtrack window. 0 = to the end.",
|
| 1396 |
+
}),
|
| 1397 |
},
|
| 1398 |
"hidden": {
|
| 1399 |
"unique_id": "UNIQUE_ID",
|
|
|
|
| 1455 |
contact_sheet="off", tone_anchor=_tone.ANCHOR_STRENGTH,
|
| 1456 |
soundtrack=None, music_gain_db=-14.0, music_duck=0.6,
|
| 1457 |
music_fit="loop", music_fade_s=1.0, soundtrack_file="",
|
| 1458 |
+
voice_start_s=0.0, voice_end_s=0.0,
|
| 1459 |
+
reference_video_start_s=0.0, reference_video_end_s=0.0,
|
| 1460 |
+
music_start_s=0.0, music_end_s=0.0,
|
| 1461 |
unique_id=None):
|
| 1462 |
dry = str(dry_run) == "on"
|
| 1463 |
draft = str(quality) == "draft"
|
|
|
|
| 1558 |
# before anything expensive starts. Each returns exactly what the socket
|
| 1559 |
# it replaced delivered, so everything downstream is unchanged.
|
| 1560 |
start_image = _media.load_image(start_image_file) if start_image_file else None
|
| 1561 |
+
reference_video = (_media.load_video(
|
| 1562 |
+
reference_video_file,
|
| 1563 |
+
start=float(reference_video_start_s), end=float(reference_video_end_s))
|
| 1564 |
+
if reference_video_file else None)
|
| 1565 |
+
voice = (_media.load_audio(voice_file,
|
| 1566 |
+
start=float(voice_start_s), end=float(voice_end_s))
|
| 1567 |
+
if voice_file else None)
|
| 1568 |
for _name, _got in (("start_image", start_image_file and start_image is None),
|
| 1569 |
("reference_video", reference_video_file and reference_video is None),
|
| 1570 |
("voice", voice_file and voice is None),
|
|
|
|
| 1585 |
for _r in ref_plan_refs:
|
| 1586 |
if not _r["file"]:
|
| 1587 |
continue
|
| 1588 |
+
_im = _media.load_image(_r["file"], cap_mp=_r.get("mp") or 0.0)
|
| 1589 |
if _im is not None and _im.shape[0] > 0:
|
| 1590 |
slot_images[_r["slot"]] = _im[:1]
|
| 1591 |
if ref_plan_refs:
|
|
|
|
| 2218 |
# not, and silently preferring the stale one would be the worse guess.
|
| 2219 |
_bed = soundtrack
|
| 2220 |
if _bed is None and soundtrack_file:
|
| 2221 |
+
_bed = _media.load_audio(soundtrack_file,
|
| 2222 |
+
start=float(music_start_s),
|
| 2223 |
+
end=float(music_end_s))
|
| 2224 |
elif _bed is not None and soundtrack_file:
|
| 2225 |
print(f"[{TAG}] soundtrack: using the wired socket, not "
|
| 2226 |
f"{soundtrack_file!r}", flush=True)
|
|
@@ -157,11 +157,80 @@ def _to_tensor(pil):
|
|
| 157 |
return torch.from_numpy(arr).unsqueeze(0)
|
| 158 |
|
| 159 |
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
"""One still as an IMAGE tensor `[1,H,W,3]`, or None.
|
| 162 |
|
| 163 |
Returns what a `Load Image` returns, so `_ref_frames` resizes it and
|
| 164 |
`tensor_digest` keys it exactly as before.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
"""
|
| 166 |
path = resolve(name, kinds={"image"})
|
| 167 |
if path is None:
|
|
@@ -171,14 +240,30 @@ def load_image(name):
|
|
| 171 |
with Image.open(path) as im:
|
| 172 |
# EXIF orientation: a phone portrait otherwise loads on its side,
|
| 173 |
# and the model would be handed a rotated face.
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
except Exception as exc:
|
| 176 |
print(f"[{TAG}] could not read reference {name!r}: {exc!r}", flush=True)
|
| 177 |
return None
|
| 178 |
|
| 179 |
|
| 180 |
-
def load_video(name, max_frames=None):
|
| 181 |
-
"""A clip as an IMAGE batch `[N,H,W,3]`, or None.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
path = resolve(name, kinds={"video"})
|
| 183 |
if path is None:
|
| 184 |
return None
|
|
@@ -189,7 +274,19 @@ def load_video(name, max_frames=None):
|
|
| 189 |
|
| 190 |
frames = []
|
| 191 |
with av.open(path) as container:
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
frames.append(frame.to_ndarray(format="rgb24"))
|
| 194 |
if max_frames and len(frames) >= int(max_frames):
|
| 195 |
break
|
|
@@ -202,7 +299,7 @@ def load_video(name, max_frames=None):
|
|
| 202 |
return None
|
| 203 |
|
| 204 |
|
| 205 |
-
def load_audio(name):
|
| 206 |
"""A take as ComfyUI's AUDIO dict, or None.
|
| 207 |
|
| 208 |
Shape is `[batch, channels, samples]`, which is what every AUDIO consumer
|
|
@@ -218,6 +315,12 @@ def load_audio(name):
|
|
| 218 |
|
| 219 |
`torchaudio` is still used for `functional.resample` in music.py; it is only
|
| 220 |
the *decoding* half of that library that is gone.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
"""
|
| 222 |
path = resolve(name, kinds={"audio"})
|
| 223 |
if path is None:
|
|
@@ -245,6 +348,11 @@ def load_audio(name):
|
|
| 245 |
print(f"[{TAG}] {name!r} decoded to no audio frames", flush=True)
|
| 246 |
return None
|
| 247 |
wav = torch.cat(chunks, dim=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
# Integer PCM is scaled by its own full range, not normalised by peak:
|
| 249 |
# a quiet take must stay quiet, and dividing by max would silently
|
| 250 |
# apply a wildly different gain per file.
|
|
|
|
| 157 |
return torch.from_numpy(arr).unsqueeze(0)
|
| 158 |
|
| 159 |
|
| 160 |
+
# The shortest window worth honouring. Below this a trim is indistinguishable
|
| 161 |
+
# from a mis-drag, and a zero-length slice would hand the encoder an empty
|
| 162 |
+
# tensor -- which fails deep inside the model with an error naming neither the
|
| 163 |
+
# file nor the widget that caused it.
|
| 164 |
+
MIN_WINDOW_S = 0.05
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def clip_window(start, end, secs):
|
| 168 |
+
"""`(start, end)` in seconds -> a sane `(lo, hi)` inside `0..secs`.
|
| 169 |
+
|
| 170 |
+
THE one definition. Four readers need to agree on what a trim means -- the
|
| 171 |
+
voice, the reference clip, the soundtrack and the peaks route -- and four
|
| 172 |
+
copies of this arithmetic would eventually disagree by a rounding rule.
|
| 173 |
+
|
| 174 |
+
`end <= 0` is the sentinel for "to the end of the file", so the widget
|
| 175 |
+
default of 0.0 means untrimmed and a file that is later replaced by a longer
|
| 176 |
+
one still plays to its new end. Anything incoherent -- reversed, negative,
|
| 177 |
+
past the end, or shorter than MIN_WINDOW_S -- returns the WHOLE file rather
|
| 178 |
+
than an empty one. A trim that did not take is a puzzle; a render that dies
|
| 179 |
+
with an empty tensor is a bug report.
|
| 180 |
+
"""
|
| 181 |
+
try:
|
| 182 |
+
secs = float(secs)
|
| 183 |
+
except (TypeError, ValueError):
|
| 184 |
+
return 0.0, 0.0
|
| 185 |
+
if secs <= 0:
|
| 186 |
+
return 0.0, 0.0
|
| 187 |
+
try:
|
| 188 |
+
lo = float(start or 0.0)
|
| 189 |
+
except (TypeError, ValueError):
|
| 190 |
+
lo = 0.0
|
| 191 |
+
try:
|
| 192 |
+
hi = float(end or 0.0)
|
| 193 |
+
except (TypeError, ValueError):
|
| 194 |
+
hi = 0.0
|
| 195 |
+
|
| 196 |
+
if hi <= 0.0 or hi > secs:
|
| 197 |
+
hi = secs
|
| 198 |
+
lo = max(0.0, min(lo, secs))
|
| 199 |
+
if hi - lo < MIN_WINDOW_S:
|
| 200 |
+
return 0.0, secs
|
| 201 |
+
return lo, hi
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _mp_cap_size(w, h, cap_mp, multiple=16):
|
| 205 |
+
"""Target size for a still under a megapixel cap. Down only.
|
| 206 |
+
|
| 207 |
+
`cap_mp <= 0` is off. Never upscales: H3 itself only ever scales references
|
| 208 |
+
down (`min(1.0, ...)` in nodes_minimax_h3.py), so a cap above the image's
|
| 209 |
+
own size would be a dial wired to nothing, and one that pretended to
|
| 210 |
+
upscale would just cost VRAM for interpolated pixels.
|
| 211 |
+
|
| 212 |
+
Edges land on a multiple of 16 because that is H3's canvas grid -- it
|
| 213 |
+
rounds to it anyway, and doing it here means the size in the log is the
|
| 214 |
+
size the encoder sees.
|
| 215 |
+
"""
|
| 216 |
+
cap = float(cap_mp or 0.0) * 1_000_000.0
|
| 217 |
+
if cap <= 0 or w * h <= cap:
|
| 218 |
+
return int(w), int(h)
|
| 219 |
+
scale = (cap / float(w * h)) ** 0.5
|
| 220 |
+
m = max(1, int(multiple))
|
| 221 |
+
return (max(m, int(w * scale) // m * m), max(m, int(h * scale) // m * m))
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def load_image(name, cap_mp=0.0):
|
| 225 |
"""One still as an IMAGE tensor `[1,H,W,3]`, or None.
|
| 226 |
|
| 227 |
Returns what a `Load Image` returns, so `_ref_frames` resizes it and
|
| 228 |
`tensor_digest` keys it exactly as before.
|
| 229 |
+
|
| 230 |
+
`cap_mp` is the per-reference megapixel budget from the rail. It is a TOKEN
|
| 231 |
+
dial, not a quality one: every reference becomes `latent_h * latent_w`
|
| 232 |
+
entries in the DiT payload and is attended over on every step of every hop,
|
| 233 |
+
so a location plate costing as much as a face is waste. 0 means no cap.
|
| 234 |
"""
|
| 235 |
path = resolve(name, kinds={"image"})
|
| 236 |
if path is None:
|
|
|
|
| 240 |
with Image.open(path) as im:
|
| 241 |
# EXIF orientation: a phone portrait otherwise loads on its side,
|
| 242 |
# and the model would be handed a rotated face.
|
| 243 |
+
im = ImageOps.exif_transpose(im)
|
| 244 |
+
tw, th = _mp_cap_size(im.width, im.height, cap_mp)
|
| 245 |
+
if (tw, th) != (im.width, im.height):
|
| 246 |
+
print(f"[{TAG}] {name}: {im.width}x{im.height} -> {tw}x{th} "
|
| 247 |
+
f"({float(cap_mp):.2f} MP cap)", flush=True)
|
| 248 |
+
im = im.resize((tw, th), Image.Resampling.LANCZOS)
|
| 249 |
+
return _to_tensor(im)
|
| 250 |
except Exception as exc:
|
| 251 |
print(f"[{TAG}] could not read reference {name!r}: {exc!r}", flush=True)
|
| 252 |
return None
|
| 253 |
|
| 254 |
|
| 255 |
+
def load_video(name, max_frames=None, start=0.0, end=0.0):
|
| 256 |
+
"""A clip as an IMAGE batch `[N,H,W,3]`, or None.
|
| 257 |
+
|
| 258 |
+
`start`/`end` are the trim window in seconds; see `clip_window`. H3 already
|
| 259 |
+
truncates a reference video to the hop length, but only from frame 0
|
| 260 |
+
(`frames[:frame_count]` in nodes_minimax_h3.py), so without this there is no
|
| 261 |
+
way to point at the segment whose motion you actually want.
|
| 262 |
+
|
| 263 |
+
Frames are counted rather than sought. A keyframe seek lands on the nearest
|
| 264 |
+
I-frame, which can be a second off -- and a bar that says 3.10 s while the
|
| 265 |
+
clip starts at 4.00 s is worse than no bar.
|
| 266 |
+
"""
|
| 267 |
path = resolve(name, kinds={"video"})
|
| 268 |
if path is None:
|
| 269 |
return None
|
|
|
|
| 274 |
|
| 275 |
frames = []
|
| 276 |
with av.open(path) as container:
|
| 277 |
+
vs = container.streams.video[0]
|
| 278 |
+
fps = float(vs.average_rate or 0) or 24.0
|
| 279 |
+
n_total = int(vs.frames or 0)
|
| 280 |
+
secs = (n_total / fps) if n_total else float(
|
| 281 |
+
(vs.duration or 0) * float(vs.time_base or 0) or 0.0)
|
| 282 |
+
lo, hi = clip_window(start, end, secs) if secs > 0 else (0.0, 0.0)
|
| 283 |
+
first = int(round(lo * fps))
|
| 284 |
+
last = int(round(hi * fps)) if hi > 0 else 0
|
| 285 |
+
for i, frame in enumerate(container.decode(video=0)):
|
| 286 |
+
if i < first:
|
| 287 |
+
continue
|
| 288 |
+
if last and i >= last:
|
| 289 |
+
break
|
| 290 |
frames.append(frame.to_ndarray(format="rgb24"))
|
| 291 |
if max_frames and len(frames) >= int(max_frames):
|
| 292 |
break
|
|
|
|
| 299 |
return None
|
| 300 |
|
| 301 |
|
| 302 |
+
def load_audio(name, start=0.0, end=0.0):
|
| 303 |
"""A take as ComfyUI's AUDIO dict, or None.
|
| 304 |
|
| 305 |
Shape is `[batch, channels, samples]`, which is what every AUDIO consumer
|
|
|
|
| 315 |
|
| 316 |
`torchaudio` is still used for `functional.resample` in music.py; it is only
|
| 317 |
the *decoding* half of that library that is gone.
|
| 318 |
+
|
| 319 |
+
`start`/`end` are the trim window in seconds; see `clip_window`. This matters
|
| 320 |
+
most for the voice: `MiniMaxH3ReferenceToVideo` hands the whole file to
|
| 321 |
+
`_encode_ref_audio` with no cap, and every latent frame that produces is a
|
| 322 |
+
token the DiT attends over on every step of every hop. An untrimmed
|
| 323 |
+
three-minute take is a large, silent, permanent tax on the render.
|
| 324 |
"""
|
| 325 |
path = resolve(name, kinds={"audio"})
|
| 326 |
if path is None:
|
|
|
|
| 348 |
print(f"[{TAG}] {name!r} decoded to no audio frames", flush=True)
|
| 349 |
return None
|
| 350 |
wav = torch.cat(chunks, dim=1)
|
| 351 |
+
# Slice before the dtype conversion below so a long take is not first
|
| 352 |
+
# promoted to float32 in full.
|
| 353 |
+
lo, hi = clip_window(start, end, wav.shape[-1] / float(sr))
|
| 354 |
+
if hi > lo and (lo > 0.0 or hi < wav.shape[-1] / float(sr)):
|
| 355 |
+
wav = wav[..., int(round(lo * sr)):int(round(hi * sr))]
|
| 356 |
# Integer PCM is scaled by its own full range, not normalised by peak:
|
| 357 |
# a quiet take must stay quiet, and dividing by max would silently
|
| 358 |
# apply a wildly different gain per file.
|
|
@@ -40,7 +40,19 @@ RETENTION = {
|
|
| 40 |
# and it exists only so the ordinal machinery below did not have to change.
|
| 41 |
# A plan from before the sockets were removed carries an authored `slot` and no
|
| 42 |
# `file`; that is detected, kept as `legacy_slot`, and reported by check().
|
| 43 |
-
REF_FIELDS = ("tag", "file", "slot", "subject", "retention", "desc", "shots"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
# Per-subject continuity text -- the half `HTCContinuityState` owned, moved here
|
| 46 |
# so it is keyed by the same subject number that owns the picture ordinals.
|
|
@@ -147,6 +159,20 @@ def _norm_ref(raw, i):
|
|
| 147 |
_fail(f"{where} (@{tag}): retention '{retention}' is not valid. "
|
| 148 |
f"Use one of: {', '.join(sorted(RETENTION))}")
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
shots = raw.get("shots")
|
| 151 |
if shots is not None:
|
| 152 |
if not isinstance(shots, (list, tuple)):
|
|
@@ -172,6 +198,7 @@ def _norm_ref(raw, i):
|
|
| 172 |
"retention": retention,
|
| 173 |
"desc": str(raw.get("desc") or "").strip(),
|
| 174 |
"shots": shots,
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
|
|
|
|
| 40 |
# and it exists only so the ordinal machinery below did not have to change.
|
| 41 |
# A plan from before the sockets were removed carries an authored `slot` and no
|
| 42 |
# `file`; that is detected, kept as `legacy_slot`, and reported by check().
|
| 43 |
+
REF_FIELDS = ("tag", "file", "slot", "subject", "retention", "desc", "shots",
|
| 44 |
+
"mp")
|
| 45 |
+
|
| 46 |
+
# Per-reference pixel budget, in megapixels. 0 (or absent) means no cap.
|
| 47 |
+
#
|
| 48 |
+
# This is a TOKEN dial, not a quality one. H3 turns each reference into
|
| 49 |
+
# `latent_h * latent_w` entries in the DiT payload and attends over all of them
|
| 50 |
+
# on every step of every hop, so a location plate costing what a face costs is
|
| 51 |
+
# waste. The floor is a picture you can still recognise a room in; there is no
|
| 52 |
+
# ceiling, because H3 only ever scales a reference DOWN
|
| 53 |
+
# (`min(1.0, ...)` in nodes_minimax_h3.py) and a cap above the file's own size
|
| 54 |
+
# would be a dial wired to nothing.
|
| 55 |
+
REF_MP_MIN = 0.3
|
| 56 |
|
| 57 |
# Per-subject continuity text -- the half `HTCContinuityState` owned, moved here
|
| 58 |
# so it is keyed by the same subject number that owns the picture ordinals.
|
|
|
|
| 159 |
_fail(f"{where} (@{tag}): retention '{retention}' is not valid. "
|
| 160 |
f"Use one of: {', '.join(sorted(RETENTION))}")
|
| 161 |
|
| 162 |
+
# Absent and 0 are the same thing -- no cap -- so an author who never
|
| 163 |
+
# opens the rail is never asked to think about this.
|
| 164 |
+
mp = raw.get("mp")
|
| 165 |
+
if mp in (None, "", 0, 0.0):
|
| 166 |
+
mp = 0.0
|
| 167 |
+
else:
|
| 168 |
+
try:
|
| 169 |
+
mp = float(mp)
|
| 170 |
+
except (TypeError, ValueError):
|
| 171 |
+
_fail(f"{where} (@{tag}): mp {mp!r} is not a number")
|
| 172 |
+
if mp < REF_MP_MIN:
|
| 173 |
+
_fail(f"{where} (@{tag}): mp {mp:g} is below the {REF_MP_MIN:g} MP "
|
| 174 |
+
f"floor. Use 0 for no cap.")
|
| 175 |
+
|
| 176 |
shots = raw.get("shots")
|
| 177 |
if shots is not None:
|
| 178 |
if not isinstance(shots, (list, tuple)):
|
|
|
|
| 198 |
"retention": retention,
|
| 199 |
"desc": str(raw.get("desc") or "").strip(),
|
| 200 |
"shots": shots,
|
| 201 |
+
"mp": mp,
|
| 202 |
}
|
| 203 |
|
| 204 |
|
|
@@ -24,11 +24,25 @@ import os as _os
|
|
| 24 |
from . import directives as _d
|
| 25 |
from . import media as _media
|
| 26 |
from . import refs as _refs
|
|
|
|
| 27 |
|
| 28 |
TAG = "HandTieClips"
|
| 29 |
ROUTE = "/h3_ref_chain/vocab"
|
| 30 |
UPLOAD_ROUTE = "/h3_ref_chain/upload"
|
| 31 |
FILES_ROUTE = "/h3_ref_chain/files"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
# A batch of stills is a handful; this is a guard against a runaway multipart
|
| 34 |
# body, not a considered product limit.
|
|
@@ -98,6 +112,60 @@ def register():
|
|
| 98 |
print(f"[{TAG}] files route failed: {exc!r}", flush=True)
|
| 99 |
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
@instance.routes.post(UPLOAD_ROUTE)
|
| 102 |
async def _upload(request):
|
| 103 |
"""Stream dropped media into the reference folder.
|
|
|
|
| 24 |
from . import directives as _d
|
| 25 |
from . import media as _media
|
| 26 |
from . import refs as _refs
|
| 27 |
+
from . import waveform as _waveform
|
| 28 |
|
| 29 |
TAG = "HandTieClips"
|
| 30 |
ROUTE = "/h3_ref_chain/vocab"
|
| 31 |
UPLOAD_ROUTE = "/h3_ref_chain/upload"
|
| 32 |
FILES_ROUTE = "/h3_ref_chain/files"
|
| 33 |
+
PEAKS_ROUTE = "/h3_ref_chain/peaks"
|
| 34 |
+
|
| 35 |
+
# Decoded waveform summaries, keyed (name, mtime, n).
|
| 36 |
+
#
|
| 37 |
+
# mtime is in the key for the same reason `media.stamp` puts it in IS_CHANGED:
|
| 38 |
+
# a filename is a stable input even when the bytes behind it change, and a trim
|
| 39 |
+
# bar drawn from the previous file is a bar that lies about where the beats are.
|
| 40 |
+
#
|
| 41 |
+
# Cleared wholesale rather than evicted one at a time. The entries are ~1.5 KB
|
| 42 |
+
# and the working set is however many references one chain uses, so an LRU would
|
| 43 |
+
# be more machinery than the thing it manages.
|
| 44 |
+
_PEAKS = {}
|
| 45 |
+
_PEAKS_MAX = 48
|
| 46 |
|
| 47 |
# A batch of stills is a handful; this is a guard against a runaway multipart
|
| 48 |
# body, not a considered product limit.
|
|
|
|
| 112 |
print(f"[{TAG}] files route failed: {exc!r}", flush=True)
|
| 113 |
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
| 114 |
|
| 115 |
+
@instance.routes.post(PEAKS_ROUTE)
|
| 116 |
+
async def _peaks(request):
|
| 117 |
+
"""A waveform summary for the trim bar: `{peaks: [...], seconds}`.
|
| 118 |
+
|
| 119 |
+
The decode runs in an executor, NOT on this coroutine. These handlers
|
| 120 |
+
share ComfyUI's event loop, and decoding a three-minute file on it
|
| 121 |
+
freezes the canvas, the queue and the progress bar together -- the same
|
| 122 |
+
constraint `llm.py`'s docstring spells out for the writer.
|
| 123 |
+
"""
|
| 124 |
+
import asyncio
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
body = await request.json()
|
| 128 |
+
except Exception:
|
| 129 |
+
body = {}
|
| 130 |
+
name = str((body or {}).get("name") or "").strip()
|
| 131 |
+
n = (body or {}).get("n") or _waveform.DEFAULT_N
|
| 132 |
+
|
| 133 |
+
path = _media.resolve(name, kinds={"audio", "video"})
|
| 134 |
+
if path is None:
|
| 135 |
+
# Not an error the panel can act on -- the file is simply gone, and
|
| 136 |
+
# the picker already says so. A flat line draws an empty bar.
|
| 137 |
+
return web.json_response({"ok": True, "peaks": [], "seconds": 0.0})
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
mtime = _os.path.getmtime(path)
|
| 141 |
+
except OSError:
|
| 142 |
+
mtime = 0.0
|
| 143 |
+
key = (name, mtime, int(n))
|
| 144 |
+
hit = _PEAKS.get(key)
|
| 145 |
+
if hit is not None:
|
| 146 |
+
return web.json_response({"ok": True, "peaks": hit[0],
|
| 147 |
+
"seconds": hit[1], "cached": True})
|
| 148 |
+
|
| 149 |
+
def work():
|
| 150 |
+
audio = _media.load_audio(name)
|
| 151 |
+
if audio is None:
|
| 152 |
+
# A video with no audio track, or a file PyAV cannot open. The
|
| 153 |
+
# bar still has to position its grips, so report the duration
|
| 154 |
+
# even when there is nothing to draw.
|
| 155 |
+
return [], 0.0
|
| 156 |
+
return _waveform.peaks(audio, n)
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
got = await asyncio.get_running_loop().run_in_executor(None, work)
|
| 160 |
+
except Exception as exc:
|
| 161 |
+
print(f"[{TAG}] peaks route failed for {name!r}: {exc!r}", flush=True)
|
| 162 |
+
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
| 163 |
+
|
| 164 |
+
if len(_PEAKS) >= _PEAKS_MAX:
|
| 165 |
+
_PEAKS.clear()
|
| 166 |
+
_PEAKS[key] = got
|
| 167 |
+
return web.json_response({"ok": True, "peaks": got[0], "seconds": got[1]})
|
| 168 |
+
|
| 169 |
@instance.routes.post(UPLOAD_ROUTE)
|
| 170 |
async def _upload(request):
|
| 171 |
"""Stream dropped media into the reference folder.
|
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Peaks and trim windows, on the CPU, with no server and no GPU.
|
| 2 |
+
|
| 3 |
+
Same shape as check_music.py: `waveform.py` and the window arithmetic in
|
| 4 |
+
`media.py` are importable without ComfyUI, so the parts that are easy to get
|
| 5 |
+
subtly wrong -- an off-by-one bucket, a reversed window, a silent file dividing
|
| 6 |
+
by zero -- are checked here rather than discovered in a render.
|
| 7 |
+
|
| 8 |
+
The window cases are the ones that matter most. A bad window does not raise; it
|
| 9 |
+
quietly slices the wrong audio, and the only symptom is a render that came out
|
| 10 |
+
different for no visible reason.
|
| 11 |
+
"""
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 16 |
+
|
| 17 |
+
import torch # noqa: E402
|
| 18 |
+
|
| 19 |
+
import media # noqa: E402
|
| 20 |
+
import waveform # noqa: E402
|
| 21 |
+
|
| 22 |
+
FAILED = []
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def ck(what, cond, note=""):
|
| 26 |
+
print(f" {'ok ' if cond else 'FAIL'} {what}{' ' + note if note else ''}")
|
| 27 |
+
if not cond:
|
| 28 |
+
FAILED.append(what)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
print("peaks")
|
| 32 |
+
|
| 33 |
+
sr = 16000
|
| 34 |
+
# A quiet bed with one loud tick a quarter of the way in. The tick is the whole
|
| 35 |
+
# point: it is the landmark a person trims against, and a mean-bucketed
|
| 36 |
+
# waveform would smear it into the noise floor.
|
| 37 |
+
sig = torch.full((sr * 8,), 0.05)
|
| 38 |
+
sig[sr * 2:sr * 2 + 200] = 0.9
|
| 39 |
+
p = waveform.reduce_peaks(sig, 240)
|
| 40 |
+
|
| 41 |
+
ck("returns exactly n buckets", len(p) == 240, f"{len(p)}")
|
| 42 |
+
ck("normalised to 1.0", max(p) == 1.0)
|
| 43 |
+
ck("nothing above 1.0", all(v <= 1.0 for v in p))
|
| 44 |
+
ck("nothing below 0", all(v >= 0.0 for v in p))
|
| 45 |
+
# 2s of 8s = bucket 60. Max-bucketing keeps it at full height; a mean would
|
| 46 |
+
# divide the tick by the bucket width and bury it under the bed.
|
| 47 |
+
peak_at = p.index(1.0)
|
| 48 |
+
ck("the transient survives bucketing", 55 <= peak_at <= 65, f"bucket {peak_at}")
|
| 49 |
+
ck("the quiet bed is not normalised up to the tick",
|
| 50 |
+
p[10] < 0.2, f"{p[10]:.3f}")
|
| 51 |
+
|
| 52 |
+
ck("n is clamped low", len(waveform.reduce_peaks(sig, 1)) == waveform.MIN_N)
|
| 53 |
+
ck("n is clamped high", len(waveform.reduce_peaks(sig, 99999)) == waveform.MAX_N)
|
| 54 |
+
ck("n defaults", len(waveform.reduce_peaks(sig, None)) == waveform.DEFAULT_N)
|
| 55 |
+
|
| 56 |
+
# Silence must not divide by zero. This is the empty-file case and it reaches
|
| 57 |
+
# the route on any brand-new upload that is still being written.
|
| 58 |
+
z = waveform.reduce_peaks(torch.zeros(sr), 240)
|
| 59 |
+
ck("silence is a flat line, not a crash", len(z) == 240 and max(z) == 0.0)
|
| 60 |
+
ck("an empty signal is a flat line", waveform.reduce_peaks(torch.zeros(0), 240) == [0.0] * 240)
|
| 61 |
+
|
| 62 |
+
# The remainder fold: a transient in the final fraction of a second is audible,
|
| 63 |
+
# so it has to be visible.
|
| 64 |
+
tail = torch.zeros(sr * 8 + 137)
|
| 65 |
+
tail[-50:] = 1.0
|
| 66 |
+
ck("a transient in the ragged tail is kept",
|
| 67 |
+
waveform.reduce_peaks(tail, 240)[-1] == 1.0)
|
| 68 |
+
|
| 69 |
+
print("\npeaks() on an AUDIO dict")
|
| 70 |
+
|
| 71 |
+
aud = {"waveform": sig.reshape(1, 1, -1), "sample_rate": sr}
|
| 72 |
+
pk, secs = waveform.peaks(aud, 240)
|
| 73 |
+
ck("seconds comes from the samples", abs(secs - 8.0) < 1e-6, f"{secs:.3f}")
|
| 74 |
+
ck("n buckets from a dict", len(pk) == 240)
|
| 75 |
+
|
| 76 |
+
stereo = {"waveform": sig.reshape(1, 1, -1).repeat(1, 2, 1), "sample_rate": sr}
|
| 77 |
+
ck("a stereo copy of a mono file draws identically",
|
| 78 |
+
waveform.peaks(stereo, 240)[0] == pk)
|
| 79 |
+
|
| 80 |
+
ck("no audio is a flat line", waveform.peaks(None, 240)[1] == 0.0)
|
| 81 |
+
ck("a dict with no waveform is a flat line",
|
| 82 |
+
waveform.peaks({"sample_rate": sr}, 240)[1] == 0.0)
|
| 83 |
+
|
| 84 |
+
print("\nclip_window")
|
| 85 |
+
|
| 86 |
+
W = media.clip_window
|
| 87 |
+
ck("0/0 is the whole file", W(0, 0, 100.0) == (0.0, 100.0))
|
| 88 |
+
ck("end 0 means to the end", W(3, 0, 100.0) == (3.0, 100.0))
|
| 89 |
+
ck("a real window is kept", W(3, 9, 100.0) == (3.0, 9.0))
|
| 90 |
+
ck("a reversed window falls back to the whole file", W(9, 3, 100.0) == (0.0, 100.0))
|
| 91 |
+
ck("a start past the end falls back", W(200, 0, 100.0) == (0.0, 100.0))
|
| 92 |
+
ck("a window shorter than the minimum falls back",
|
| 93 |
+
W(5, 5.0 + media.MIN_WINDOW_S / 2, 100.0) == (0.0, 100.0))
|
| 94 |
+
ck("an end past the file is clamped to it", W(3, 500, 100.0) == (3.0, 100.0))
|
| 95 |
+
ck("a negative start is clamped to 0", W(-5, 9, 100.0) == (0.0, 9.0))
|
| 96 |
+
ck("a zero-length file yields nothing", W(0, 0, 0.0) == (0.0, 0.0))
|
| 97 |
+
ck("junk does not raise", W("x", None, 100.0) == (0.0, 100.0))
|
| 98 |
+
# The load-bearing one. A window is only ever a window; it can never hand the
|
| 99 |
+
# encoder an empty tensor, whatever the widgets say.
|
| 100 |
+
ck("no input produces an empty window",
|
| 101 |
+
all(W(a, b, 100.0)[1] > W(a, b, 100.0)[0]
|
| 102 |
+
for a in (-1, 0, 3, 99.99, 100, 500) for b in (-1, 0, 0.01, 3, 100, 500)))
|
| 103 |
+
|
| 104 |
+
print("\nmegapixel cap")
|
| 105 |
+
|
| 106 |
+
C = media._mp_cap_size
|
| 107 |
+
ck("no cap is a no-op", C(1920, 1080, 0) == (1920, 1080))
|
| 108 |
+
ck("a cap above the image is a no-op", C(1920, 1080, 8.0) == (1920, 1080))
|
| 109 |
+
w, h = C(1920, 1080, 0.5)
|
| 110 |
+
ck("a cap below the image scales it down", w * h <= 0.5e6, f"{w}x{h}")
|
| 111 |
+
ck("the cap keeps the aspect", abs((w / h) - (1920 / 1080)) < 0.05, f"{w / h:.3f}")
|
| 112 |
+
ck("edges land on H3's 16 px grid", w % 16 == 0 and h % 16 == 0, f"{w}x{h}")
|
| 113 |
+
ck("a portrait image stays portrait", C(1080, 1920, 0.3)[0] < C(1080, 1920, 0.3)[1])
|
| 114 |
+
ck("never smaller than one grid cell", min(C(64, 64, 0.3)) >= 16)
|
| 115 |
+
|
| 116 |
+
print()
|
| 117 |
+
if FAILED:
|
| 118 |
+
print(f"WAVEFORM CHECK: {len(FAILED)} FAILURE(S)")
|
| 119 |
+
for f in FAILED:
|
| 120 |
+
print(f" - {f}")
|
| 121 |
+
sys.exit(1)
|
| 122 |
+
print("WAVEFORM CHECK: all clear")
|
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Peaks for the trim bar, computed here rather than in the browser.
|
| 2 |
+
|
| 3 |
+
The obvious way to draw a waveform in a panel is to fetch the file and run it
|
| 4 |
+
through `AudioContext.decodeAudioData`. That was the first design and it is
|
| 5 |
+
wrong at this scale: a 173 s stereo file decodes to roughly 66 MB of Float32 in
|
| 6 |
+
the tab, per control, for a picture 240 pixels wide. PromptMasterLD reaches the
|
| 7 |
+
same conclusion from the other direction -- it has four separate trim controls
|
| 8 |
+
and not one `decodeAudioData` call between them.
|
| 9 |
+
|
| 10 |
+
So the server sends 240 numbers. The browser holds 240 numbers. The decode
|
| 11 |
+
happens once, on a thread, behind an mtime-keyed cache.
|
| 12 |
+
|
| 13 |
+
Three details are load-bearing:
|
| 14 |
+
|
| 15 |
+
**Bucket by max, not by mean.** A mean flattens transients into a smooth
|
| 16 |
+
sausage, and transients are the only landmarks you can trim against -- the
|
| 17 |
+
whole point of looking at the picture is to find the downbeat or the start of
|
| 18 |
+
the sentence.
|
| 19 |
+
|
| 20 |
+
**`seconds` comes from the decoded sample count.** MP3 Xing/LAME headers
|
| 21 |
+
routinely report double the real duration, and a duration that lies makes every
|
| 22 |
+
position on the bar lie with it. We already decode, so the honest number is
|
| 23 |
+
free.
|
| 24 |
+
|
| 25 |
+
**No ComfyUI import.** Same rule as `music.py`, `plan.py` and `refs.py`: the DSP
|
| 26 |
+
is testable on a CPU with no server, which is what `tools/check_waveform.py`
|
| 27 |
+
does.
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
TAG = "HandTieClips"
|
| 32 |
+
|
| 33 |
+
# 240 is PromptMasterLD's default and it is a good one: a trim bar is a few
|
| 34 |
+
# hundred CSS pixels wide, so more buckets than this buys nothing you can see
|
| 35 |
+
# and costs JSON on every panel open.
|
| 36 |
+
DEFAULT_N = 240
|
| 37 |
+
MIN_N = 24
|
| 38 |
+
MAX_N = 600
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def reduce_peaks(mono, n=DEFAULT_N):
|
| 42 |
+
"""A 1-D waveform -> `n` bucket maxima in 0..1.
|
| 43 |
+
|
| 44 |
+
`mono` is any 1-D sequence of floats (a torch tensor, a numpy array, a
|
| 45 |
+
list). Returns a plain list so the caller can hand it straight to
|
| 46 |
+
`json_response` without a tensor library in the loop.
|
| 47 |
+
"""
|
| 48 |
+
n = max(MIN_N, min(MAX_N, int(n or DEFAULT_N)))
|
| 49 |
+
total = len(mono)
|
| 50 |
+
if total <= 0:
|
| 51 |
+
return [0.0] * n
|
| 52 |
+
|
| 53 |
+
import torch # noqa: PLC0415
|
| 54 |
+
|
| 55 |
+
x = mono if isinstance(mono, torch.Tensor) else torch.as_tensor(mono)
|
| 56 |
+
x = x.detach().to(torch.float32).abs().flatten()
|
| 57 |
+
|
| 58 |
+
# Pad up to a whole number of buckets rather than looping in Python. A
|
| 59 |
+
# 173 s file is 8.3M samples and a per-bucket slice in the interpreter is
|
| 60 |
+
# measurable; one reshape and one max over a dim is not.
|
| 61 |
+
per = max(1, total // n)
|
| 62 |
+
keep = per * n
|
| 63 |
+
if keep < total:
|
| 64 |
+
# Fold the remainder into the last bucket instead of dropping it, or a
|
| 65 |
+
# transient in the final fraction of a second would vanish from the
|
| 66 |
+
# picture while still being audible in the file.
|
| 67 |
+
head = x[:keep].reshape(n, per).amax(dim=1)
|
| 68 |
+
head[-1] = torch.maximum(head[-1], x[keep:].amax())
|
| 69 |
+
buckets = head
|
| 70 |
+
else:
|
| 71 |
+
buckets = x[:keep].reshape(n, per).amax(dim=1)
|
| 72 |
+
|
| 73 |
+
top = float(buckets.max())
|
| 74 |
+
if top > 0:
|
| 75 |
+
buckets = buckets / top
|
| 76 |
+
# 3 dp is well past what a 240 px canvas can render, and it keeps the JSON
|
| 77 |
+
# payload around 1.5 KB instead of 5 KB.
|
| 78 |
+
return [round(float(v), 3) for v in buckets]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def peaks(audio, n=DEFAULT_N):
|
| 82 |
+
"""A ComfyUI AUDIO dict -> `(peaks, seconds)`.
|
| 83 |
+
|
| 84 |
+
`audio` is what `media.load_audio` returns: `{"waveform": [B,C,S],
|
| 85 |
+
"sample_rate": int}`. A missing or empty take returns a flat line rather
|
| 86 |
+
than raising, so a panel drawing a control for a file that has gone missing
|
| 87 |
+
shows an empty bar instead of an error.
|
| 88 |
+
"""
|
| 89 |
+
if not audio:
|
| 90 |
+
return [0.0] * max(MIN_N, min(MAX_N, int(n or DEFAULT_N))), 0.0
|
| 91 |
+
|
| 92 |
+
import torch # noqa: PLC0415
|
| 93 |
+
|
| 94 |
+
wav = audio.get("waveform")
|
| 95 |
+
sr = int(audio.get("sample_rate") or 0)
|
| 96 |
+
if wav is None or sr <= 0:
|
| 97 |
+
return [0.0] * max(MIN_N, min(MAX_N, int(n or DEFAULT_N))), 0.0
|
| 98 |
+
|
| 99 |
+
x = wav if isinstance(wav, torch.Tensor) else torch.as_tensor(wav)
|
| 100 |
+
while x.dim() > 1:
|
| 101 |
+
# Mean across batch and channels, not sum: summing two correlated
|
| 102 |
+
# channels doubles the amplitude and the normalise below would hide it,
|
| 103 |
+
# but a mono file and its own stereo copy should draw identically.
|
| 104 |
+
x = x.mean(dim=0)
|
| 105 |
+
samples = int(x.shape[-1])
|
| 106 |
+
return reduce_peaks(x, n), samples / float(sr)
|
|
@@ -602,7 +602,13 @@
|
|
| 602 |
0.6,
|
| 603 |
"loop",
|
| 604 |
1.0,
|
| 605 |
-
""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
],
|
| 607 |
"title": "H3 Ref2VA Chain - showcase 6x7s"
|
| 608 |
},
|
|
|
|
| 602 |
0.6,
|
| 603 |
"loop",
|
| 604 |
1.0,
|
| 605 |
+
"",
|
| 606 |
+
0,
|
| 607 |
+
0,
|
| 608 |
+
0,
|
| 609 |
+
0,
|
| 610 |
+
0,
|
| 611 |
+
0
|
| 612 |
],
|
| 613 |
"title": "H3 Ref2VA Chain - showcase 6x7s"
|
| 614 |
},
|
|
@@ -605,7 +605,13 @@
|
|
| 605 |
0.6,
|
| 606 |
"loop",
|
| 607 |
1.0,
|
| 608 |
-
""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
],
|
| 610 |
"title": "H3 Ref2VA Chain - starter"
|
| 611 |
},
|
|
|
|
| 605 |
0.6,
|
| 606 |
"loop",
|
| 607 |
1.0,
|
| 608 |
+
"",
|
| 609 |
+
0,
|
| 610 |
+
0,
|
| 611 |
+
0,
|
| 612 |
+
0,
|
| 613 |
+
0,
|
| 614 |
+
0
|
| 615 |
],
|
| 616 |
"title": "H3 Ref2VA Chain - starter"
|
| 617 |
},
|