Sandpies commited on
Commit
9529ccd
·
1 Parent(s): 437f9ba

Upsampler: chunk_join=stepped replaces continue; fix pin-strength shadowing

Browse files

chunk_join=stepped: the chunks are re-drawn together, as spatial tiles
always were. The film starts as the upscaled chunks crossfaded into one
latent; each sigma step runs every chunk on its slice of that one state
(step 1 with its slice of one film-wide noise field) and merges the
results, each chunk weighted down linearly across the steps it shares, so
neighbours converge on one face and a chunk's off-tone last step counts
least. Every chunk is still drawn from the source: nothing accumulates.
Whole-frame chunks only; tiled or out-of-VRAM runs fall back to crossfade.

Removed chunk_join=continue: on a GPU it made a visible seam at the
hand-over (traced to a tone step at the held/free boundary) and did not
keep the face closer.

Fix: with the original (non-Regen2K) recipe and structure_lock > 0, chunk
2+ crashed -- the per-chunk lock anchor shadowed upsample()'s pin-strength
argument. Introduced on this branch; regression test added.

CHANGELOG.md CHANGED
@@ -15,12 +15,13 @@ pack — the published package excludes them.
15
  - **New node: HTC Video Frames (lean)** (`HTCVideoFrames`). A drop-in for Get Video Components after Load Video, with the same images / audio / fps plus `frame_count` and the VIDEO. `start_seconds`, `max_frames`, `frame_rate` (24 fps nearest-frame re-time) and `resize` (megapixels or long edge, never enlarging) are applied while decoding, and the IMAGE is filled once from 8-bit frames: peak RAM was 1.08x the output against 2.01x for core-style decoding. It replaces Get Video Components in `workflows/HandTieClips_Upsample_Video.json`.
16
  - **New node: HTC Load Video (path)** (`HTCLoadVideoPath`). It reads a video where it is, so there's no upload, no copy into `input/` and no 100 MB upload limit (no `--max-upload-size` needed). Paste a path (Copy as path quotes are fine) or a name relative to `input/`. Outputs VIDEO, fps, frame_count and an info line (resolution, fps with variable-rate warning, length, codec, bit depth, audio), and the node re-runs only when the file changes.
17
  - **Fix (file to file, Video Frames):** an audio track FFmpeg can't decode (e.g. an iPhone's spatial-audio APAC track) is now skipped, as core does. Decoding one crashes the process.
18
- - **Upsampler: `attention`, `chunk_join`, `chunking`, `tiling`, `lock_anchor`** (standalone nodes and `HTC Upsample Settings`). `attention=sparse` applies ComfyUI's Model Sparse Attention (sol-attn) to every re-draw step, and runs dense on a ComfyUI without it. `chunk_join=continue` holds each chunk's shared frames to what the previous chunk drew (a per-frame sampler mask) and draws on from there, instead of crossfading two independent re-draws; whole-frame chunks only. `chunking` / `tiling` (both on, as before) each *allow* a split when the clip doesn't fit `tile_tokens`. Time is cut before space, chunks are never under 68 frames (Regen2K), and both off re-draws the clip in one whole-frame pass. Chunking off on a clip over 4x `tile_tokens` (about 18 s at 2 MP; in `HandTieClips` that is usually the whole film) chunks anyway. Tiling off never tiles, and running out of VRAM is an error that says what to change. `lock_anchor=source` (new default; it only matters with `structure_lock` > 0) holds the large shapes to the clip's own latent, enlarged by plain interpolation, instead of the 3D upscaler's output, so the upscaler's own errors are pulled back too. Defaults are otherwise unchanged.
19
  - **Fix: the 3D latent upscale's CPU fallback no longer sticks.** One out-of-memory error used to run every remaining chunk on the CPU in float32. Now only that chunk does, and the next one goes back to the GPU.
20
  - Conditioning rows (references) now count against `tile_tokens` when tiles are cut (`_cond_extra_tokens` was unused).
21
  - **Upsampler: `tile_tokens = 0` learns the machine** (new default on the standalone nodes; `upsample_tile_tokens` 0 in `HandTieClips`, whose stock value stays 70000). It starts from the budget this GPU learned last time (70000 the first time) and measures each chunk's activation memory (peak minus what stays resident, since ComfyUI's dynamic loading fills spare VRAM with weights). It then stores the next run's starting budget in `ComfyUI/user/htc_upsampler_memory.json`: grown toward 85% of the card when there was room (at most 2x a run), or the budget that worked after an out-of-memory error, never returning near the failing value. It's keyed by GPU, VRAM, attention mode and the low-VRAM chunking. A fixed number behaves exactly as before and teaches nothing.
22
  - **Draft: `tile_context = global pass`** (standalone nodes and `HTC Upsample Settings`; default `none`). For chunks that must be tiled, the chunk is first re-drawn whole-frame at the largest size that fits the budget, with the same steps. The tiles then hold their large shapes to that result (`structure_lock` at full strength, at `lock_sigma`). A tile only sees its own window, and that is where invention comes from; this way the layout comes from a pass that saw everything, and the tiles add detail. Untiled chunks are unaffected. Not yet measured on a GPU.
23
  - **H3 Video Upsampler: `redraw_start`** (default 0.8 = the recipe). It rebuilds Regen2K's 3-step shift-6 grid from another starting noise. A side-by-side test showed the face drift comes from the re-draw itself, not from the 3D upscaler (denoise 0 matched the source), nor the seed, the LoRA strength or a text prompt. Starting from less noise keeps faces closer to the source at some cost in sharpening. It is the last widget on both nodes, so existing graphs keep their values.
 
24
 
25
  ## 3.2.0 — 2026-10-30
26
 
 
15
  - **New node: HTC Video Frames (lean)** (`HTCVideoFrames`). A drop-in for Get Video Components after Load Video, with the same images / audio / fps plus `frame_count` and the VIDEO. `start_seconds`, `max_frames`, `frame_rate` (24 fps nearest-frame re-time) and `resize` (megapixels or long edge, never enlarging) are applied while decoding, and the IMAGE is filled once from 8-bit frames: peak RAM was 1.08x the output against 2.01x for core-style decoding. It replaces Get Video Components in `workflows/HandTieClips_Upsample_Video.json`.
16
  - **New node: HTC Load Video (path)** (`HTCLoadVideoPath`). It reads a video where it is, so there's no upload, no copy into `input/` and no 100 MB upload limit (no `--max-upload-size` needed). Paste a path (Copy as path quotes are fine) or a name relative to `input/`. Outputs VIDEO, fps, frame_count and an info line (resolution, fps with variable-rate warning, length, codec, bit depth, audio), and the node re-runs only when the file changes.
17
  - **Fix (file to file, Video Frames):** an audio track FFmpeg can't decode (e.g. an iPhone's spatial-audio APAC track) is now skipped, as core does. Decoding one crashes the process.
18
+ - **Upsampler: `attention`, `chunk_join`, `chunking`, `tiling`, `lock_anchor`** (standalone nodes and `HTC Upsample Settings`). `attention=sparse` applies ComfyUI's Model Sparse Attention (sol-attn) to every re-draw step, and runs dense on a ComfyUI without it. `chunk_join=stepped` re-draws the chunks together, as spatial tiles always were: after each step, the frames two chunks share are merged into one state, starting from one film-wide noise field, so neighbouring chunks converge on one face and a chunk's off-tone last step counts least. Whole-frame chunks only, and it falls back to crossfade otherwise. (A `continue` mode, which held the shared frames to the previous chunk, was tried and removed: in a side-by-side test it made a visible seam at the hand-over and did not keep the face any closer.) `chunking` / `tiling` (both on, as before) each *allow* a split when the clip doesn't fit `tile_tokens`. Time is cut before space, chunks are never under 68 frames (Regen2K), and both off re-draws the clip in one whole-frame pass. Chunking off on a clip over 4x `tile_tokens` (about 18 s at 2 MP; in `HandTieClips` that is usually the whole film) chunks anyway. Tiling off never tiles, and running out of VRAM is an error that says what to change. `lock_anchor=source` (new default; it only matters with `structure_lock` > 0) holds the large shapes to the clip's own latent, enlarged by plain interpolation, instead of the 3D upscaler's output, so the upscaler's own errors are pulled back too. Defaults are otherwise unchanged.
19
  - **Fix: the 3D latent upscale's CPU fallback no longer sticks.** One out-of-memory error used to run every remaining chunk on the CPU in float32. Now only that chunk does, and the next one goes back to the GPU.
20
  - Conditioning rows (references) now count against `tile_tokens` when tiles are cut (`_cond_extra_tokens` was unused).
21
  - **Upsampler: `tile_tokens = 0` learns the machine** (new default on the standalone nodes; `upsample_tile_tokens` 0 in `HandTieClips`, whose stock value stays 70000). It starts from the budget this GPU learned last time (70000 the first time) and measures each chunk's activation memory (peak minus what stays resident, since ComfyUI's dynamic loading fills spare VRAM with weights). It then stores the next run's starting budget in `ComfyUI/user/htc_upsampler_memory.json`: grown toward 85% of the card when there was room (at most 2x a run), or the budget that worked after an out-of-memory error, never returning near the failing value. It's keyed by GPU, VRAM, attention mode and the low-VRAM chunking. A fixed number behaves exactly as before and teaches nothing.
22
  - **Draft: `tile_context = global pass`** (standalone nodes and `HTC Upsample Settings`; default `none`). For chunks that must be tiled, the chunk is first re-drawn whole-frame at the largest size that fits the budget, with the same steps. The tiles then hold their large shapes to that result (`structure_lock` at full strength, at `lock_sigma`). A tile only sees its own window, and that is where invention comes from; this way the layout comes from a pass that saw everything, and the tiles add detail. Untiled chunks are unaffected. Not yet measured on a GPU.
23
  - **H3 Video Upsampler: `redraw_start`** (default 0.8 = the recipe). It rebuilds Regen2K's 3-step shift-6 grid from another starting noise. A side-by-side test showed the face drift comes from the re-draw itself, not from the 3D upscaler (denoise 0 matched the source), nor the seed, the LoRA strength or a text prompt. Starting from less noise keeps faces closer to the source at some cost in sharpening. It is the last widget on both nodes, so existing graphs keep their values.
24
+ - **Fix:** with the original (non-Regen2K) recipe and `structure_lock` > 0, chunk 2 onward crashed, because the lock's anchor latent shadowed the first-frame pin strength. This was introduced on this branch, and it is now fixed and covered by a test.
25
 
26
  ## 3.2.0 — 2026-10-30
27
 
tiled_upsampler.py CHANGED
@@ -102,15 +102,15 @@ LOCK_SIGMA = 2.0
102
  # tile_tokens; neither forces one. chunking cuts time into 102-frame chunks
103
  # (no shorter than 68 -- Regen2K's 3 steps from 0.8 invent on short pieces);
104
  # tiling cuts space. Time is cut before space: a tile sees part of the frame,
105
- # which is where invention comes from, while a join in time is held together
106
- # by chunk_join=continue and structure_lock. Both off = the whole clip in one
107
- # whole-frame pass.
108
  # lock_anchor: what structure_lock holds the large shapes to. source = the
109
  # clip's own latent, enlarged by plain interpolation (nothing invented, so
110
  # errors the 3D upscaler adds -- a blown patch, a colour shift -- are pulled
111
  # back too); upscaler = the 3D upscaler's output.
112
  ATTENTION_MODES = ("dense", "sparse")
113
- JOIN_MODES = ("crossfade", "continue")
114
  LOCK_ANCHORS = ("source", "upscaler")
115
  # tile_context (draft): what a tile knows about the rest of its frame. none =
116
  # only its own window (and structure_lock's anchor). global pass = before the
@@ -1143,15 +1143,12 @@ def _apply_low_vram_patches(model, attn_chunks=4, ffn_chunks=2):
1143
  return model
1144
 
1145
 
1146
- def sample_tile(model, cond, sampler, sigmas, video, audio, noise, seed, lock=None,
1147
- known_steps=0):
1148
  """One tile through `sigmas`, audio frozen. -> the tile's video latent.
1149
 
1150
  `noise` is a (video, audio) pair to use as is. SamplerCustomAdvanced's
1151
  path, with a positive-only guider at cfg 1 and the x0 preview. `lock` is
1152
- a `structure_lock_fn` for this tile, or None. The first `known_steps`
1153
- latent steps of `video` are held as given (the sampler keeps them at
1154
- every sigma) and the rest is drawn after them.
1155
  """
1156
  import comfy.model_management as mm
1157
  import comfy.nested_tensor as nt
@@ -1162,13 +1159,9 @@ def sample_tile(model, cond, sampler, sigmas, video, audio, noise, seed, lock=No
1162
 
1163
  samples = comfy.sample.fix_empty_latent_channels(
1164
  model, nt.NestedTensor((video, audio)), None, None)
1165
- vmask = torch.ones((1, 1, int(video.shape[2]) if known_steps else 1,
1166
- video.shape[3], video.shape[4]),
1167
- device=video.device, dtype=torch.float32)
1168
- if known_steps:
1169
- vmask[:, :, :int(known_steps)] = 0.0
1170
  mask = nt.NestedTensor((
1171
- vmask,
 
1172
  torch.zeros((1, 32, 2, audio.shape[-1]),
1173
  device=audio.device, dtype=audio.dtype)))
1174
  guider = comfy.samplers.CFGGuider(model)
@@ -1348,7 +1341,7 @@ def global_pass(model, cond, sampler, sigmas, video, audio, seed, budget,
1348
  def redraw_chunk(model, cond, sampler, sigmas, video, audio, seed, factor=1,
1349
  budget=TOKEN_BUDGET, overlap_px=TILE_OVERLAP_PX,
1350
  detail_sigma=0.0, noise_video=None, structure_lock=STRUCTURE_LOCK,
1351
- lock_sigma=LOCK_SIGMA, known=None, anchor=None, allow_tiles=True,
1352
  tile_context="none"):
1353
  """Every tile stepped together, merged under the feather after each step.
1354
 
@@ -1359,10 +1352,7 @@ def redraw_chunk(model, cond, sampler, sigmas, video, audio, seed, factor=1,
1359
  so low-frequency tile-grid shifts are stripped while fine detail is kept.
1360
  `structure_lock` > 0 holds every tile's large shapes to its slice of
1361
  `video` (the upscaled source) at every step; see `structure_lock_fn`.
1362
- `known` = (steps, latent) continues the chunk from frames already drawn:
1363
- its first `steps` latent steps are held to `latent` and the rest is drawn
1364
- after them. Whole-frame chunks only; a tiled chunk ignores it (the
1365
- caller's crossfade then joins it as before). `anchor` replaces `video`
1366
  as what structure_lock holds the large shapes to. `allow_tiles` False
1367
  keeps the chunk whole-frame whatever the budget, and an OOM is an error
1368
  instead of a retry in tiles. `tile_context` "global pass" re-draws the
@@ -1432,22 +1422,13 @@ def redraw_chunk(model, cond, sampler, sigmas, video, audio, seed, factor=1,
1432
  # DynamicVRAM DiT on every step.
1433
  r0, c0, rows, cols, tcond, _m = prepared[0]
1434
  xt = x[:, :, :, r0:r0 + rows, c0:c0 + cols].contiguous()
1435
- ks = 0
1436
- if known is not None and int(known[0]) > 0:
1437
- ks = min(int(known[0]), t - 1)
1438
- xt = xt.clone() # a whole-frame slice may be `video` itself
1439
- xt[:, :, :ks] = known[1][:, :, :ks].to(xt.device, xt.dtype)
1440
  noise = (nv[:, :, :, r0:r0 + rows, c0:c0 + cols].contiguous(), na)
1441
  ot = sample_tile(model, tcond, sampler, sigmas, xt, audio, noise, seed,
1442
- lock=locks[0], known_steps=ks)
1443
  _calls += 1
1444
  x = ot.to(device=dev, dtype=dt)
1445
  del ot, xt
1446
  else:
1447
- if known is not None and int(known[0]) > 0:
1448
- print(f"[{TAG}] chunk_join=continue needs a whole-frame chunk; this one is "
1449
- f"{grid[0]}x{grid[1]} tiles, so it is crossfaded", flush=True)
1450
- known = None
1451
  if str(tile_context) == "global pass":
1452
  g, g_note = global_pass(
1453
  model, cond, sampler, sigmas, video, audio, seed,
@@ -1514,13 +1495,78 @@ def redraw_chunk(model, cond, sampler, sigmas, video, audio, seed, factor=1,
1514
 
1515
  if float(detail_sigma or 0.0) > 0.0:
1516
  x = _highpass_detail_merge(video, x, sigma=float(detail_sigma))
1517
- if known is not None and int(known[0]) > 0:
1518
- ks = min(int(known[0]), t - 1)
1519
- x[:, :, :ks] = known[1][:, :, :ks].to(x.device, x.dtype)
1520
  _LAST_BUDGET[0] = cur_budget
1521
  return x, cur_budget
1522
 
1523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1524
  def _crossfade(a, b, dim):
1525
  n = a.shape[dim]
1526
  wgt = torch.linspace(0.0, 1.0, n, device=a.device, dtype=a.dtype)
@@ -1781,7 +1827,49 @@ def upsample(model, conditioning, latent, sampler, upscaler, megapixels,
1781
  _oom_total = 0
1782
  init_budget = cur_budget = int(budget)
1783
  mem_samples, _peak_all = [], 0
1784
- for i, (k0, f0, k1, f1) in enumerate(plan):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1785
  a0, a1 = audio_span(f0, f1)
1786
  cv = upscaled_cvs[i]
1787
  upscaled_cvs[i] = None
@@ -1816,13 +1904,8 @@ def upsample(model, conditioning, latent, sampler, upscaler, megapixels,
1816
  print(f"[{TAG}] chunk {i + 1}/{len(plan)}: keeping lowered budget "
1817
  f"{cur_budget} ({_gc[0]}x{_gc[1]} tiles; would fit "
1818
  f"{_gi[0]}x{_gi[1]} at {init_budget})", flush=True)
1819
- anchor = anchors[i]
1820
  anchors[i] = None
1821
- known = None
1822
- if str(chunk_join) == "continue" and i > 0 and end_v > k0:
1823
- _ks = min(end_v - k0, int(cv.shape[2]) - 1)
1824
- if _ks > 0:
1825
- known = (_ks, out_v[:, :, k0:k0 + _ks])
1826
  _tr = time.perf_counter()
1827
  if torch.cuda.is_available():
1828
  torch.cuda.reset_peak_memory_stats()
@@ -1831,7 +1914,7 @@ def upsample(model, conditioning, latent, sampler, upscaler, megapixels,
1831
  noise_video=(None if film_nv is None
1832
  else film_nv[:, :, k0:k1]),
1833
  structure_lock=structure_lock, lock_sigma=lock_sigma,
1834
- known=known, anchor=anchor, allow_tiles=tiling,
1835
  tile_context=tile_context)
1836
  rv, cur_budget = (_rc if isinstance(_rc, tuple) else (_rc, cur_budget))
1837
  _redraw_s += time.perf_counter() - _tr
@@ -2169,11 +2252,13 @@ SETTINGS_TYPE = "HTC_UPSAMPLE"
2169
  ATTENTION_TIP = ("dense = exact attention. sparse = ComfyUI's Model Sparse Attention (sol-attn) "
2170
  "on every step: much faster on long sequences (rdh measured ~4x on a 2K "
2171
  "refine), close to dense but approximate -- compare faces before relying on it.")
2172
- JOIN_TIP = ("crossfade = neighbouring chunks are re-drawn independently and blended over "
2173
- "the frames they share. continue = the shared frames are held to what the "
2174
- "previous chunk drew and the next chunk carries on from them, so faces do not "
2175
- "settle into a slightly different face at a join. Whole-frame chunks only "
2176
- "(a tiled chunk falls back to crossfade).")
 
 
2177
  CHUNKING_TIP = ("On = the clip may be cut into 102-frame chunks (never under 68) when it does "
2178
  "not fit tile_tokens. Off = the whole clip in one pass: no joins, VRAM grows with "
2179
  "its length. Clips over 4x tile_tokens (~18 s at 2 MP and 70000; in HandTieClips "
 
102
  # tile_tokens; neither forces one. chunking cuts time into 102-frame chunks
103
  # (no shorter than 68 -- Regen2K's 3 steps from 0.8 invent on short pieces);
104
  # tiling cuts space. Time is cut before space: a tile sees part of the frame,
105
+ # which is where invention comes from, while a join in time can be stepped
106
+ # together (chunk_join=stepped). Both off = the whole clip in one whole-frame
107
+ # pass.
108
  # lock_anchor: what structure_lock holds the large shapes to. source = the
109
  # clip's own latent, enlarged by plain interpolation (nothing invented, so
110
  # errors the 3D upscaler adds -- a blown patch, a colour shift -- are pulled
111
  # back too); upscaler = the 3D upscaler's output.
112
  ATTENTION_MODES = ("dense", "sparse")
113
+ JOIN_MODES = ("crossfade", "stepped")
114
  LOCK_ANCHORS = ("source", "upscaler")
115
  # tile_context (draft): what a tile knows about the rest of its frame. none =
116
  # only its own window (and structure_lock's anchor). global pass = before the
 
1143
  return model
1144
 
1145
 
1146
+ def sample_tile(model, cond, sampler, sigmas, video, audio, noise, seed, lock=None):
 
1147
  """One tile through `sigmas`, audio frozen. -> the tile's video latent.
1148
 
1149
  `noise` is a (video, audio) pair to use as is. SamplerCustomAdvanced's
1150
  path, with a positive-only guider at cfg 1 and the x0 preview. `lock` is
1151
+ a `structure_lock_fn` for this tile, or None.
 
 
1152
  """
1153
  import comfy.model_management as mm
1154
  import comfy.nested_tensor as nt
 
1159
 
1160
  samples = comfy.sample.fix_empty_latent_channels(
1161
  model, nt.NestedTensor((video, audio)), None, None)
 
 
 
 
 
1162
  mask = nt.NestedTensor((
1163
+ torch.ones((1, 1, 1, video.shape[3], video.shape[4]),
1164
+ device=video.device, dtype=torch.float32),
1165
  torch.zeros((1, 32, 2, audio.shape[-1]),
1166
  device=audio.device, dtype=audio.dtype)))
1167
  guider = comfy.samplers.CFGGuider(model)
 
1341
  def redraw_chunk(model, cond, sampler, sigmas, video, audio, seed, factor=1,
1342
  budget=TOKEN_BUDGET, overlap_px=TILE_OVERLAP_PX,
1343
  detail_sigma=0.0, noise_video=None, structure_lock=STRUCTURE_LOCK,
1344
+ lock_sigma=LOCK_SIGMA, anchor=None, allow_tiles=True,
1345
  tile_context="none"):
1346
  """Every tile stepped together, merged under the feather after each step.
1347
 
 
1352
  so low-frequency tile-grid shifts are stripped while fine detail is kept.
1353
  `structure_lock` > 0 holds every tile's large shapes to its slice of
1354
  `video` (the upscaled source) at every step; see `structure_lock_fn`.
1355
+ `anchor` replaces `video`
 
 
 
1356
  as what structure_lock holds the large shapes to. `allow_tiles` False
1357
  keeps the chunk whole-frame whatever the budget, and an OOM is an error
1358
  instead of a retry in tiles. `tile_context` "global pass" re-draws the
 
1422
  # DynamicVRAM DiT on every step.
1423
  r0, c0, rows, cols, tcond, _m = prepared[0]
1424
  xt = x[:, :, :, r0:r0 + rows, c0:c0 + cols].contiguous()
 
 
 
 
 
1425
  noise = (nv[:, :, :, r0:r0 + rows, c0:c0 + cols].contiguous(), na)
1426
  ot = sample_tile(model, tcond, sampler, sigmas, xt, audio, noise, seed,
1427
+ lock=locks[0])
1428
  _calls += 1
1429
  x = ot.to(device=dev, dtype=dt)
1430
  del ot, xt
1431
  else:
 
 
 
 
1432
  if str(tile_context) == "global pass":
1433
  g, g_note = global_pass(
1434
  model, cond, sampler, sigmas, video, audio, seed,
 
1495
 
1496
  if float(detail_sigma or 0.0) > 0.0:
1497
  x = _highpass_detail_merge(video, x, sigma=float(detail_sigma))
 
 
 
1498
  _LAST_BUDGET[0] = cur_budget
1499
  return x, cur_budget
1500
 
1501
 
1502
+ def _step_weights(plan, i, device):
1503
+ """Chunk i's weight per latent step in a stepped merge: 1 inside, ramping
1504
+ linearly to near 0 across the steps it shares with each neighbour, so the
1505
+ end of a chunk -- where it is weakest -- counts least."""
1506
+ k0, _f0, k1, _f1 = plan[i]
1507
+ w = torch.ones(k1 - k0, dtype=torch.float32, device=device)
1508
+ if i > 0:
1509
+ ov = max(0, plan[i - 1][2] - k0)
1510
+ if ov:
1511
+ w[:ov] = torch.arange(1, ov + 1, dtype=torch.float32, device=device) / (ov + 1)
1512
+ if i + 1 < len(plan):
1513
+ ov = max(0, k1 - plan[i + 1][0])
1514
+ if ov:
1515
+ w[-ov:] = torch.minimum(w[-ov:], torch.arange(ov, 0, -1, dtype=torch.float32,
1516
+ device=device) / (ov + 1))
1517
+ return w.view(1, 1, -1, 1, 1)
1518
+
1519
+
1520
+ def redraw_stepped(model, plan, videos, audios, conds, sampler, sigmas, seed,
1521
+ locks=None, mem_samples=None):
1522
+ """Every chunk stepped TOGETHER (chunk_join=stepped). -> the film's video latent.
1523
+
1524
+ `videos` are the chunks' upscaled latents (plan order), `audios` their
1525
+ audio slices, `conds` their conditioning, `locks` their structure locks.
1526
+ The film starts as the chunks crossfaded into one latent; each sigma step
1527
+ runs every chunk from its slice of that one state (the first step with its
1528
+ slice of one film-wide noise field) and merges the results under
1529
+ `_step_weights` before the next step -- what spatial tiles have always
1530
+ done, along time. Whole-frame chunks only (the caller checks)."""
1531
+ c, h, w = int(videos[0].shape[1]), int(videos[0].shape[3]), int(videos[0].shape[4])
1532
+ tv = int(plan[-1][2])
1533
+ dev, dt = videos[0].device, videos[0].dtype
1534
+ film = torch.zeros((1, c, tv, h, w), dtype=dt, device=dev)
1535
+ dummy = torch.zeros((1, 32, 2, 1), dtype=dt, device=dev)
1536
+ end = 0
1537
+ for (k0, f0, k1, _f1), v in zip(plan, videos):
1538
+ end, _ = _join_into(film, dummy, end, 0, v.to(dev, dt), dummy[..., :0], k0, f0)
1539
+ nv = torch.randn((1, c, tv, h, w), generator=torch.Generator().manual_seed(int(seed)),
1540
+ dtype=torch.float32, device="cpu").to(dev, dt)
1541
+ weights = [_step_weights(plan, i, dev) for i in range(len(plan))]
1542
+ x = film
1543
+ for step in range(len(sigmas) - 1):
1544
+ two = sigmas[step:step + 2]
1545
+ acc = torch.zeros_like(x, dtype=torch.float32)
1546
+ wsum = torch.zeros((1, 1, tv, 1, 1), dtype=torch.float32, device=dev)
1547
+ for i, (k0, _f0, k1, _f1) in enumerate(plan):
1548
+ import comfy.model_management as mm
1549
+ mm.throw_exception_if_processing_interrupted()
1550
+ xt = x[:, :, k0:k1].contiguous()
1551
+ noise = ((nv[:, :, k0:k1].contiguous() if step == 0 else torch.zeros_like(xt)),
1552
+ torch.zeros_like(audios[i]))
1553
+ if torch.cuda.is_available():
1554
+ torch.cuda.reset_peak_memory_stats()
1555
+ ot = sample_tile(model, conds[i], sampler, two, xt, audios[i], noise, seed,
1556
+ lock=(locks[i] if locks else None))
1557
+ if mem_samples is not None and torch.cuda.is_available():
1558
+ mem_samples.append((seq_len(w * SPATIAL, h * SPATIAL, k1 - k0),
1559
+ int(torch.cuda.max_memory_allocated()
1560
+ - torch.cuda.memory_allocated()),
1561
+ int(torch.cuda.memory_allocated())))
1562
+ acc[:, :, k0:k1] += ot.to(device=dev, dtype=torch.float32) * weights[i]
1563
+ wsum[:, :, k0:k1] += weights[i]
1564
+ del ot, xt
1565
+ x = (acc / wsum.clamp(min=1e-8)).to(dtype=dt)
1566
+ del acc
1567
+ return x
1568
+
1569
+
1570
  def _crossfade(a, b, dim):
1571
  n = a.shape[dim]
1572
  wgt = torch.linspace(0.0, 1.0, n, device=a.device, dtype=a.dtype)
 
1827
  _oom_total = 0
1828
  init_budget = cur_budget = int(budget)
1829
  mem_samples, _peak_all = [], 0
1830
+ stepped = False
1831
+ if str(chunk_join) == "stepped" and sigmas is not None and len(plan) > 1:
1832
+ shape = upscaled_cvs[0].shape
1833
+ fits = all(tile_grid(int(shape[3]), int(shape[4]), int(k1 - k0), factor,
1834
+ max(4096, int(budget)), overlap_px)[1][:2] == (1, 1)
1835
+ for (k0, _f0, k1, _f1) in plan) if tiling else True
1836
+ if factor > 1 or not fits:
1837
+ print(f"[{TAG}] chunk_join=stepped needs whole-frame chunks"
1838
+ + (" (and no h3upscale guide)" if factor > 1 else "")
1839
+ + "; crossfading instead", flush=True)
1840
+ else:
1841
+ conds, auds, locks = [], [], []
1842
+ for i, (k0, f0, k1, f1) in enumerate(plan):
1843
+ a0, a1 = audio_span(f0, f1)
1844
+ auds.append(audio[:, :, :, a0:min(a1, ta)].contiguous())
1845
+ cv = upscaled_cvs[i]
1846
+ conds.append(cond_for_chunk(clean_refs(conditioning(f0, f1)) if per_chunk
1847
+ else cond, f0, f1, cv.shape[3], cv.shape[4]))
1848
+ locks.append(structure_lock_fn(anchors[i] if anchors[i] is not None else cv,
1849
+ structure_lock, lock_sigma)
1850
+ if float(structure_lock) > 0.0 else None)
1851
+ _tr = time.perf_counter()
1852
+ try:
1853
+ out_v = redraw_stepped(model, plan, upscaled_cvs, auds, conds, sampler,
1854
+ sigmas, seed, locks, mem_samples)
1855
+ stepped = True
1856
+ except Exception as exc:
1857
+ if not _is_oom(exc):
1858
+ raise
1859
+ print(f"[{TAG}] chunk_join=stepped ran out of VRAM ({exc}); crossfading "
1860
+ "chunk by chunk instead", flush=True)
1861
+ _free_all_gpu()
1862
+ mem_samples.clear()
1863
+ _redraw_s += time.perf_counter() - _tr
1864
+ if stepped:
1865
+ print(f"[{TAG}] chunk_join=stepped: {len(plan)} chunks x {len(sigmas) - 1} "
1866
+ f"steps in {len(plan) * (len(sigmas) - 1)} sampler calls, "
1867
+ f"{_redraw_s:.1f} s", flush=True)
1868
+ if torch.cuda.is_available() and mem_samples:
1869
+ _peak_all = max(a + r for _t, a, r in mem_samples)
1870
+ out_a[...] = audio[..., :int(total_a)]
1871
+ end_v, end_a = int(tv), int(total_a)
1872
+ for i, (k0, f0, k1, f1) in enumerate(plan if not stepped else []):
1873
  a0, a1 = audio_span(f0, f1)
1874
  cv = upscaled_cvs[i]
1875
  upscaled_cvs[i] = None
 
1904
  print(f"[{TAG}] chunk {i + 1}/{len(plan)}: keeping lowered budget "
1905
  f"{cur_budget} ({_gc[0]}x{_gc[1]} tiles; would fit "
1906
  f"{_gi[0]}x{_gi[1]} at {init_budget})", flush=True)
1907
+ lock_anchor_t = anchors[i] # not `anchor`: that is the pin strength above
1908
  anchors[i] = None
 
 
 
 
 
1909
  _tr = time.perf_counter()
1910
  if torch.cuda.is_available():
1911
  torch.cuda.reset_peak_memory_stats()
 
1914
  noise_video=(None if film_nv is None
1915
  else film_nv[:, :, k0:k1]),
1916
  structure_lock=structure_lock, lock_sigma=lock_sigma,
1917
+ anchor=lock_anchor_t, allow_tiles=tiling,
1918
  tile_context=tile_context)
1919
  rv, cur_budget = (_rc if isinstance(_rc, tuple) else (_rc, cur_budget))
1920
  _redraw_s += time.perf_counter() - _tr
 
2252
  ATTENTION_TIP = ("dense = exact attention. sparse = ComfyUI's Model Sparse Attention (sol-attn) "
2253
  "on every step: much faster on long sequences (rdh measured ~4x on a 2K "
2254
  "refine), close to dense but approximate -- compare faces before relying on it.")
2255
+ JOIN_TIP = ("crossfade = every chunk is re-drawn on its own and the 17 frames neighbours share "
2256
+ "are blended. stepped = the chunks are stepped TOGETHER, the way spatial tiles "
2257
+ "are: after each of the re-draw's steps, the frames two chunks share are merged "
2258
+ "into one state before either takes the next step, from one film-wide noise "
2259
+ "field. Two chunks then converge on one face instead of meeting as two, and a "
2260
+ "chunk's last step (which tends to come out off-tone) weighs least. Same compute, "
2261
+ "one sampler call per chunk per step. Whole-frame chunks only.")
2262
  CHUNKING_TIP = ("On = the clip may be cut into 102-frame chunks (never under 68) when it does "
2263
  "not fit tile_tokens. Off = the whole clip in one pass: no joins, VRAM grows with "
2264
  "its length. Clips over 4x tile_tokens (~18 s at 2 MP and 70000; in HandTieClips "
workflows/HandTieClips_Upsample_Video.json CHANGED
@@ -24,7 +24,7 @@
24
  "Node name for S&R": "MarkdownNote"
25
  },
26
  "widgets_values": [
27
- "## H3 Video Upsampler: upscale a finished clip\n\n1. **Load Video**: pick the clip (put it in `ComfyUI/input`). Any size; 24 fps is what H3\n assumes, other rates work but the soundtrack conditions the re-draw loosely.\n2. **Queue.** HTC Save Video writes it to `video/HANDTIECLIPS/upsampled` at the clip's own fps,\n with its own soundtrack untouched (NVENC when there is a GPU). Set its `frame_gen` to x2 for\n double the frame rate (RTX GPU + `nvidia-vfx`).\n\n### The node\n- **megapixels**: output size, aspect kept (2 = about 1920x1080).\n- **denoise**: Regen2K LoRA strength for the 3-step Euler re-draw on `[0.8, 24/35, 0.48, 0]` (0.8 is the default; 0 skips the re-draw and only enlarges).\n- **lora**: the Open-H3-Regenerate-2K LoRA (`open_h3_regenerate_2k_r256pp_bf16.safetensors` by default).\n- **tile_tokens**: 0 = auto (default): learned on this GPU run by run, starting at 70000, grown when there is room, lowered for good after running out of VRAM. A number fixes it (lower = shorter chunks, then tiles).\n- **redraw_seed**: noise seed for the Regen2K re-draw (42 by default).\n- **structure_lock**: 0 = off. Holds the re-draw to the clip's large shapes (faces, hands, line work) at every step so chunks and tiles stop inventing; try 0.5-1, higher for animation.\n- **lock_sigma**: what structure_lock counts as large, in latent cells (2 = 32 px). 1-1.5 also holds eyes and mouths on medium shots.\n- **fps** is wired from Get Video Components: H3 runs on a 24 fps clock, so for other rates **off_24_audio** retimes what the re-draw hears (retime) or conditions on silence. The output soundtrack is always the original. A 48 fps clip interpolated from a 24 fps render: upscale the 24 fps frames and use frame_gen x2 instead (half the work, exact sync).\n- **conditioning** (optional): a CLIPTextEncode of what the clip shows, typed or written by a VLM. Unwired = zeroed-out conditioning.\n- **audio_vae** wired = the re-draw pins the real soundtrack; unwired = it pins silence.\n\nMouths and motion come from the clip; only fine detail is re-drawn.\n\n**Video Frames (lean)** replaces Get Video Components: same images / audio / fps, about half the RAM, and `start_seconds`, `max_frames`, `frame_rate` and `resize` applied while decoding. For long clips use the file-to-file workflow instead.\n\n**Speed and joins:** `attention` sparse = ComfyUI's sparse attention (much faster on long sequences, approximate: compare faces). `chunk_join` continue = each chunk carries on from the frames the previous one drew instead of blending two re-draws. `chunking` / `tiling` allow a split when the clip doesn't fit `tile_tokens` (chunks are shortened first, never under 68 frames); both off = the whole clip in one whole-frame pass, for short clips on big cards. `lock_anchor` source holds `structure_lock` to the clip itself, so the 3D upscaler's own errors are pulled back too."
28
  ]
29
  },
30
  {
 
24
  "Node name for S&R": "MarkdownNote"
25
  },
26
  "widgets_values": [
27
+ "## H3 Video Upsampler: upscale a finished clip\n\n1. **Load Video**: pick the clip (put it in `ComfyUI/input`). Any size; 24 fps is what H3\n assumes, other rates work but the soundtrack conditions the re-draw loosely.\n2. **Queue.** HTC Save Video writes it to `video/HANDTIECLIPS/upsampled` at the clip's own fps,\n with its own soundtrack untouched (NVENC when there is a GPU). Set its `frame_gen` to x2 for\n double the frame rate (RTX GPU + `nvidia-vfx`).\n\n### The node\n- **megapixels**: output size, aspect kept (2 = about 1920x1080).\n- **denoise**: Regen2K LoRA strength for the 3-step Euler re-draw on `[0.8, 24/35, 0.48, 0]` (0.8 is the default; 0 skips the re-draw and only enlarges).\n- **lora**: the Open-H3-Regenerate-2K LoRA (`open_h3_regenerate_2k_r256pp_bf16.safetensors` by default).\n- **tile_tokens**: 0 = auto (default): learned on this GPU run by run, starting at 70000, grown when there is room, lowered for good after running out of VRAM. A number fixes it (lower = shorter chunks, then tiles).\n- **redraw_seed**: noise seed for the Regen2K re-draw (42 by default).\n- **structure_lock**: 0 = off. Holds the re-draw to the clip's large shapes (faces, hands, line work) at every step so chunks and tiles stop inventing; try 0.5-1, higher for animation.\n- **lock_sigma**: what structure_lock counts as large, in latent cells (2 = 32 px). 1-1.5 also holds eyes and mouths on medium shots.\n- **fps** is wired from Get Video Components: H3 runs on a 24 fps clock, so for other rates **off_24_audio** retimes what the re-draw hears (retime) or conditions on silence. The output soundtrack is always the original. A 48 fps clip interpolated from a 24 fps render: upscale the 24 fps frames and use frame_gen x2 instead (half the work, exact sync).\n- **conditioning** (optional): a CLIPTextEncode of what the clip shows, typed or written by a VLM. Unwired = zeroed-out conditioning.\n- **audio_vae** wired = the re-draw pins the real soundtrack; unwired = it pins silence.\n\nMouths and motion come from the clip; only fine detail is re-drawn.\n\n**Video Frames (lean)** replaces Get Video Components: same images / audio / fps, about half the RAM, and `start_seconds`, `max_frames`, `frame_rate` and `resize` applied while decoding. For long clips use the file-to-file workflow instead.\n\n**Speed and joins:** `attention` sparse = ComfyUI's sparse attention (much faster on long sequences, approximate: compare faces). `chunk_join` stepped = neighbouring chunks are re-drawn together and merged after every step, so they converge on one face instead of meeting as two. `chunking` / `tiling` allow a split when the clip doesn't fit `tile_tokens` (chunks are shortened first, never under 68 frames); both off = the whole clip in one whole-frame pass, for short clips on big cards. `lock_anchor` source holds `structure_lock` to the clip itself, so the 3D upscaler's own errors are pulled back too."
28
  ]
29
  },
30
  {
workflows/HandTieClips_Upsample_Video_File.json CHANGED
@@ -24,7 +24,7 @@
24
  "Node name for S&R": "MarkdownNote"
25
  },
26
  "widgets_values": [
27
- "## H3 Video Upsampler (file to file)\n\nThe same re-draw as the IMAGE workflow, for clips of any length on any machine: the video is read, encoded, re-drawn, decoded and saved **a chunk at a time**, so RAM stays flat however long the film is (the IMAGE path holds the whole clip twice; a minute at 48 fps is over 100 GB).\n\n1. **Load Video**: pick the clip. Frame rate and soundtrack come from the file.\n2. **Queue.** The MP4 lands in `video/H3_upsampled` with the original soundtrack.\n\n### The node\n- **megapixels / denoise / lora / redraw_seed**: as in the IMAGE workflow. **tile_tokens** 0 = auto: learned on this GPU run by run.\n- **structure_lock** (0 = off) holds faces, hands and line work to the clip; try 0.5-1. **lock_sigma** 1-1.5 also holds eyes and mouths on medium shots.\n- **off_24_audio**: H3 runs on a 24 fps clock; for other rates `retime` keeps the re-draw's lip sync.\n- **frame_gen / codec / quality**: as in H3 Save Video. x2 doubles the frame rate (RTX + `nvidia-vfx`).\n- **conditioning** (optional): a CLIPTextEncode of the clip's look. Unwired = zeroed out.\n- **frame_rate**: `24 (H3 native)` re-times the clip to 24 fps while reading (nearest frame), so the soundtrack lines up exactly and a 48 fps clip costs half the re-draw; add `frame_gen` x2 to save 48 again.\n- **reuse_encoded**: the next run on the same clip skips reading and encoding (kept in ComfyUI's temp folder until restart). Handy for A/B runs of structure_lock or seeds.\n\n**Speed and joins:** `attention` sparse = ComfyUI's sparse attention (much faster on long sequences, approximate: compare faces). `chunk_join` continue = each chunk carries on from the frames the previous one drew instead of blending two re-draws. `chunking` / `tiling` allow a split when the clip doesn't fit `tile_tokens` (chunks are shortened first, never under 68 frames); both off = the whole clip in one whole-frame pass, for short clips on big cards. `lock_anchor` source holds `structure_lock` to the clip itself, so the 3D upscaler's own errors are pulled back too."
28
  ]
29
  },
30
  {
 
24
  "Node name for S&R": "MarkdownNote"
25
  },
26
  "widgets_values": [
27
+ "## H3 Video Upsampler (file to file)\n\nThe same re-draw as the IMAGE workflow, for clips of any length on any machine: the video is read, encoded, re-drawn, decoded and saved **a chunk at a time**, so RAM stays flat however long the film is (the IMAGE path holds the whole clip twice; a minute at 48 fps is over 100 GB).\n\n1. **Load Video**: pick the clip. Frame rate and soundtrack come from the file.\n2. **Queue.** The MP4 lands in `video/H3_upsampled` with the original soundtrack.\n\n### The node\n- **megapixels / denoise / lora / redraw_seed**: as in the IMAGE workflow. **tile_tokens** 0 = auto: learned on this GPU run by run.\n- **structure_lock** (0 = off) holds faces, hands and line work to the clip; try 0.5-1. **lock_sigma** 1-1.5 also holds eyes and mouths on medium shots.\n- **off_24_audio**: H3 runs on a 24 fps clock; for other rates `retime` keeps the re-draw's lip sync.\n- **frame_gen / codec / quality**: as in H3 Save Video. x2 doubles the frame rate (RTX + `nvidia-vfx`).\n- **conditioning** (optional): a CLIPTextEncode of the clip's look. Unwired = zeroed out.\n- **frame_rate**: `24 (H3 native)` re-times the clip to 24 fps while reading (nearest frame), so the soundtrack lines up exactly and a 48 fps clip costs half the re-draw; add `frame_gen` x2 to save 48 again.\n- **reuse_encoded**: the next run on the same clip skips reading and encoding (kept in ComfyUI's temp folder until restart). Handy for A/B runs of structure_lock or seeds.\n\n**Speed and joins:** `attention` sparse = ComfyUI's sparse attention (much faster on long sequences, approximate: compare faces). `chunk_join` stepped = neighbouring chunks are re-drawn together and merged after every step, so they converge on one face instead of meeting as two. `chunking` / `tiling` allow a split when the clip doesn't fit `tile_tokens` (chunks are shortened first, never under 68 frames); both off = the whole clip in one whole-frame pass, for short clips on big cards. `lock_anchor` source holds `structure_lock` to the clip itself, so the 3D upscaler's own errors are pulled back too."
28
  ]
29
  },
30
  {