Sandpies commited on
Commit
a2cfa42
·
1 Parent(s): efd6a3e

trim windows on every media input, and a per-reference pixel budget

Browse files

Using 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 CHANGED
@@ -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(reference_video_file)
1500
- if reference_video_file else None)
1501
- voice = _media.load_audio(voice_file) if voice_file else None
 
 
 
 
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)
media.py CHANGED
@@ -157,11 +157,80 @@ def _to_tensor(pil):
157
  return torch.from_numpy(arr).unsqueeze(0)
158
 
159
 
160
- def load_image(name):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return _to_tensor(ImageOps.exif_transpose(im))
 
 
 
 
 
 
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
- for frame in container.decode(video=0):
 
 
 
 
 
 
 
 
 
 
 
 
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.
refs.py CHANGED
@@ -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
 
routes.py CHANGED
@@ -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.
tools/check_waveform.py ADDED
@@ -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")
waveform.py ADDED
@@ -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)
workflows/HandTieClips_Showcase.json CHANGED
@@ -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
  },
workflows/HandTieClips_Starter.json CHANGED
@@ -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
  },