dzmdd's picture
Upload folder using huggingface_hub
e488ce7 verified
Raw History Blame Contribute Delete
30.1 kB
"""Rolling-window regeneration driven by ComfyUI's node-expansion API.
One node (H3 Window Expand) reads a hold map, plans N windows against a
dilated-frame budget, and returns an EXPANSION: a graph containing the whole
regeneration chain once per window, wired into a chained splice. The user
presses queue once. The sampler stays a real SamplerCustomAdvanced node in
the expanded graph, so per-step progress, previews and interrupt behave the
way they do anywhere else.
Window k's body is the shipped segment recipe, unchanged:
H3WindowCrop -> H3TimeSmear -> VAEEncode -> H3V2VInit
-> SamplerCustomAdvanced -> VAEDecode -> H3ExactRecover
(audio: VAEDecodeAudio -> H3AudioRecover)
-> H3WindowSeam -> H3SegmentSplice(baseline = window k-1)
H3WindowCrop and H3WindowSeam exist because the planner, not the crop node,
owns the per-frame holds and the per-side seam policy. They are placed by
H3 Window Expand; you can wire them by hand but there is no reason to.
Seam policy (shared with the other rolling-window drivers so the three can
be compared): the budget is counted in DILATED frames; window boundaries
snap to cold cuts (hold == 1 on both sides) and, failing that, to token
boundaries; a boundary is never placed inside a ramp shoulder; a hot cut
(a boundary forced inside a burst) is served by pin-and-trim, not by a
crossfade.
Nothing here edits motion.py. It imports from it.
"""
import json
import torch
from .motion import COST_EXP, _legal_ceil, _tok_start_frame, _token_count
# ---------------------------------------------------------------------------
# planner
# ---------------------------------------------------------------------------
def _bursts(holds):
"""Maximal runs of held frames as inclusive (first, last) pairs."""
out, start = [], None
for f, h in enumerate(holds):
if h > 1 and start is None:
start = f
elif h <= 1 and start is not None:
out.append((start, f - 1))
start = None
if start is not None:
out.append((start, len(holds) - 1))
return out
def _plateaus(holds):
"""Per-frame plateau value: the maximum hold of the burst a frame is in,
or 1 outside every burst. A frame whose hold is below its plateau is on a
ramp shoulder, and the policy refuses to cut there: the two sides would
disagree about that frame's hold and the same world frame would be
regenerated at two different rates."""
plat = [1] * len(holds)
for a, b in _bursts(holds):
m = max(holds[a:b + 1])
for f in range(a, b + 1):
plat[f] = m
return plat
def _tok_starts(limit):
"""Dilated-frame indices that begin a latent token, up to `limit`."""
out, t = set(), 0
while True:
f = _tok_start_frame(t)
if f > limit:
return out
out.add(f)
t += 1
def _seg_holds(holds, a, b, body_a, body_b, hot_in, hot_out):
"""Holds for the cropped segment [a..b].
Body frames keep their world hold. A handle frame is hold 1 on a COLD
side (it is real-time baseline context, which is what the shipped
single-window path does) and INHERITS the world hold on a HOT side, so
both sides of a mid-burst seam get repaired at the same temporal rate.
"""
out = []
for f in range(a, b + 1):
if body_a <= f <= body_b:
out.append(holds[f])
elif f < body_a and hot_in:
out.append(holds[f])
elif f > body_b and hot_out:
out.append(holds[f])
else:
out.append(1)
return out
def _grow_to_grid(holds, a, b, body_a, body_b, hot_in, hot_out, n):
"""Land sum(seg_holds) exactly on the 17k+5 grid by growing a hold-1
handle rather than letting H3TimeSmear absorb the remainder into the
final hold. A hold-1 frame adds exactly 1, so the deficit is spendable
one frame at a time. Falls back to today's tail pad when the clip runs
out of room on both sides."""
for _ in range(64):
sh = _seg_holds(holds, a, b, body_a, body_b, hot_in, hot_out)
need = _legal_ceil(sum(sh)) - sum(sh)
if need <= 0:
return a, b
if b < n - 1 and not hot_out:
b += 1
elif a > 0 and not hot_in:
a -= 1
else:
return a, b
return a, b
def _window(holds, body_a, body_b, handle, n, hot_in, hot_out, grid_grow=True):
"""Build one window record from its body span. A handle exists on both
kinds of seam; the difference is that a hot handle inherits the world
hold instead of being pinned to hold 1."""
a = max(0, body_a - handle)
b = min(n - 1, body_b + handle)
if grid_grow:
a, b = _grow_to_grid(holds, a, b, body_a, body_b, hot_in, hot_out, n)
sh = _seg_holds(holds, a, b, body_a, body_b, hot_in, hot_out)
dil = _legal_ceil(sum(sh))
return {"a": a, "b": b, "body_a": body_a, "body_b": body_b,
"holds": sh, "dilated": dil, "tokens": _token_count(dil),
"hot_in": hot_in, "hot_out": hot_out,
"handle_in": body_a - a, "handle_out": b - body_b}
def plan_windows(holds, budget, handle=12, snap_search=8, max_windows=12,
grid_grow=True):
"""Greedy plan: the largest span of held material whose dilated length
fits the budget, cut at the coldest legal boundary available.
Returns (windows, notes). A window dict carries its world span, its
per-frame segment holds, its dilated length and its seam types.
"""
n = len(holds)
notes = []
bursts = _bursts(holds)
if not bursts:
return [], ["hold map has no held frames; nothing to regenerate"]
plat = _plateaus(holds)
toks = _tok_starts(sum(holds) + 64)
cum = [0] * (n + 1) # dilated position of world frame
for f in range(n):
cum[f + 1] = cum[f] + holds[f]
# every legal place a window may END (inclusive), in world frames
cold_ends = {b for _, b in bursts}
hot_ends = set()
for a, b in bursts:
for f in range(a, b):
# cut between f and f+1: both sides must be at the plateau, so
# neither side sits on a ramp shoulder
if holds[f] == plat[f] and holds[f + 1] == plat[f + 1]:
hot_ends.add(f)
burst_starts = {a for a, _ in bursts}
windows, cursor, guard = [], bursts[0][0], 0
while cursor <= bursts[-1][1] and guard < max_windows:
guard += 1
body_a = cursor
hot_in = body_a not in burst_starts # we cut into a burst last time
cands = sorted(e for e in (cold_ends | hot_ends) if e >= body_a)
fits_cold, fits_hot = [], []
for e in cands:
hot_out = e not in cold_ends
w = _window(holds, body_a, e, handle, n, hot_in, hot_out,
grid_grow)
if w["dilated"] <= budget:
(fits_hot if hot_out else fits_cold).append((e, w))
elif fits_cold or fits_hot:
break # cost is monotone in e
if fits_cold:
e, w = fits_cold[-1]
elif fits_hot:
e, w = fits_hot[-1]
snapped = None
# token snap: look back a few frames for a boundary that lands on
# a token start in the dilated timeline
for e2, w2 in reversed(fits_hot):
if e - e2 > snap_search:
break
if cum[e2 + 1] in toks:
snapped, e, w = (e - e2), e2, w2
break
notes.append(f"window {len(windows)}: NO cold cut fits the "
f"{budget}f budget, forced a hot cut after f{e} "
f"(inside a burst)" +
(f", snapped back {snapped}f onto the token "
f"boundary at dilated f{cum[e + 1]}"
if snapped is not None else
", no token boundary within snap_search"))
else:
e = cands[0] if cands else bursts[-1][1]
hot_out = e not in cold_ends
w = _window(holds, body_a, e, handle, n, hot_in, hot_out,
grid_grow)
notes.append(f"window {len(windows)}: budget {budget}f is below "
f"the smallest legal window ({w['dilated']}f); "
f"emitting it anyway")
w["k"] = len(windows)
windows.append(w)
cursor = e + 1
while cursor < n and holds[cursor] <= 1:
cursor += 1 # skip to the next held frame
if cursor >= n:
break
if guard >= max_windows and cursor <= bursts[-1][1]:
notes.append(f"stopped at max_windows={max_windows}; frames "
f"f{cursor}-f{bursts[-1][1]} are NOT covered")
for i, w in enumerate(windows):
w["seam_in"] = "hot" if w["hot_in"] else ("edge" if i == 0 else "cold")
w["seam_out"] = "hot" if w["hot_out"] else (
"edge" if i == len(windows) - 1 else "cold")
return windows, notes
def plan_report(windows, notes, holds, budget, fps=24, s_per_step=0.0,
est_steps=18):
"""The price tag, in the shape H3ManualHoldMap uses."""
n = len(holds)
full = _legal_ceil(sum(holds))
t_full = _token_count(full)
lines = [f"world {n}f, single-pass dilated {full}f ({t_full} tokens); "
f"budget {budget} dilated f/window -> {len(windows)} window(s)"]
tot, cov = 0.0, 0
for w in windows:
rel = (w["tokens"] / t_full) ** COST_EXP
tot += rel
cov += w["body_b"] - w["body_a"] + 1
pad = w["dilated"] - sum(w["holds"])
lines.append(
f" w{w['k']}: world f{w['a']}-f{w['b']} "
f"(body f{w['body_a']}-f{w['body_b']}), dilated {w['dilated']}f "
f"/ {w['tokens']} tok, {rel:.2f}x one full pass, seams "
f"{w['seam_in']}/{w['seam_out']}" +
(f", TAIL PAD {pad}f (both handles hot, no hold-1 frame left to "
f"grow; H3 Time Smear freezes the last frame)" if pad else ""))
lines.append(f" total {tot:.2f}x one full pass per step "
f"({abs(1 - tot) * 100:.0f}% "
f"{'cheaper' if tot < 1 else 'MORE EXPENSIVE'} than one "
f"window), "
f"{cov}/{sum(1 for h in holds if h > 1)} held frames covered")
dup = sum(w["b"] - w["a"] + 1 for w in windows) - cov
lines.append(f" {dup} handle frames regenerated as context "
f"({dup / max(1, sum(w['b'] - w['a'] + 1 for w in windows)) * 100:.0f}% "
f"of generated frames are duplication)")
if s_per_step > 0:
lines.append(f" roughly {tot * s_per_step * est_steps / 60:.0f} min "
f"of sampling at {s_per_step:g} s/step x {est_steps} "
f"steps for a full pass")
lines.append(f" RAM: the chained splice holds {len(windows)} full-world "
f"image copies in the cache at once")
lines += [" " + s for s in notes]
return "\n".join(lines)
# ---------------------------------------------------------------------------
# body nodes placed inside the expansion
# ---------------------------------------------------------------------------
# What H3WindowCrop is told about a window. Deliberately NOT the fade widths
# or the trim: those live on H3WindowSeam, so retuning a seam does not change
# the cache signature of anything that had to be sampled.
CROP_SPEC_KEYS = ("k", "a", "b", "world_len", "holds", "handle_in",
"handle_out", "seam_in", "seam_out")
class H3WindowCrop:
"""Cut one planned window out of the world. Placed by H3 Window Expand."""
DESCRIPTION = (
"EXPERIMENTAL (alpha), new 2026-08-12; the classic pipeline nodes "
"are unchanged.\n\n"
"Internal to H3 Window Expand: crops the world to one planned "
"window and emits that window's exact per-frame hold map plus a "
"splice map in world coordinates. Unlike H3 Segment Crop it does "
"not derive the window from the hold map, because the planner has "
"already chosen the span, the per-side handle widths and whether "
"each handle is real-time (cold seam) or hold-inheriting (hot "
"seam). Wire it by hand only if you are debugging a plan.")
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"images": ("IMAGE", {"tooltip": "world frames (or the running splice)"}),
"window_spec": ("STRING", {"default": "", "multiline": True,
"tooltip": "JSON from H3 Window Expand's planner"}),
}}
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "IMAGE", "IMAGE", "STRING")
RETURN_NAMES = ("images", "hold_map", "splice_map", "first_frame",
"last_frame", "report")
FUNCTION = "crop"
CATEGORY = "image/minimax/motion"
def crop(self, images, window_spec):
images = images.detach().cpu()
w = json.loads(window_spec)
a, b, holds = w["a"], w["b"], w["holds"]
n = int(images.shape[0])
assert w["world_len"] <= n, (w["world_len"], n)
assert len(holds) == b - a + 1, (len(holds), b - a + 1)
seg = images[a:b + 1]
# the fade widths are NOT here on purpose: H3 Window Seam writes
# them, so retuning feather_frames invalidates the seam and the
# splice and leaves the sampled windows in cache
splice = json.dumps({"start": a, "end": b, "world_len": w["world_len"],
"handle_in": w["handle_in"],
"handle_out": w["handle_out"]})
dil = _legal_ceil(sum(holds))
report = (f"w{w['k']}: f{a}-f{b} ({b - a + 1}f of {w['world_len']}f), "
f"dilated {dil}f / {_token_count(dil)} tok, seams "
f"{w['seam_in']}/{w['seam_out']}")
return (seg, json.dumps({"holds": holds, "world_len": b - a + 1}),
splice, seg[:1].clone(), seg[-1:].clone(), report)
class H3WindowSeam:
"""Pin-and-trim at a hot seam: discard the leading handle of a recovered
window instead of crossfading two independent takes of it."""
DESCRIPTION = (
"EXPERIMENTAL (alpha), new 2026-08-12; the classic pipeline nodes "
"are unchanged.\n\n"
"Internal to H3 Window Expand. At a COLD seam (both sides of the "
"boundary at hold 1) it is a passthrough that only carries the "
"planner's per-side crossfade widths into the splice map. At a HOT "
"seam (a boundary forced inside a burst) it drops trim_head frames "
"from the front of the recovered window and moves the splice start "
"forward by the same amount, so the previous window's output is "
"kept and this window's second take of those frames is thrown away. "
"Crossfading them instead would dissolve between two takes of the "
"fastest motion in the clip, and for audio it would double or smear "
"a percussive hit no matter how wide the fade.")
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"segment": ("IMAGE", {"tooltip": "recovered window, world clock"}),
"splice_map": ("STRING", {"default": "", "forceInput": True}),
"trim_head": ("INT", {"default": 0, "min": 0, "max": 96}),
"fade_in": ("INT", {"default": 6, "min": 0, "max": 48,
"tooltip": "crossfade width the splice will use on the leading seam; 0 = hard cut"}),
"fade_out": ("INT", {"default": 6, "min": 0, "max": 48}),
}, "optional": {
"segment_audio": ("AUDIO",),
"fps": ("INT", {"default": 24, "min": 1, "max": 120}),
}}
RETURN_TYPES = ("IMAGE", "STRING", "AUDIO")
RETURN_NAMES = ("segment", "splice_map", "segment_audio")
FUNCTION = "seam"
CATEGORY = "image/minimax/motion"
def seam(self, segment, splice_map, trim_head=0, fade_in=6, fade_out=6,
segment_audio=None, fps=24):
sp = json.loads(splice_map)
t = max(0, min(int(trim_head), int(segment.shape[0]) - 1))
# H3SegmentSplice fades over min(feather_frames, handle_in/out), and
# the expansion hands it feather_frames=24, so these two numbers are
# what actually sets each side's crossfade width
sp["handle_in"], sp["handle_out"] = int(fade_in), int(fade_out)
if t:
segment = segment[t:]
sp["start"] = sp["start"] + t
sp["handle_in"] = 0
if segment_audio is not None:
sr = segment_audio["sample_rate"]
cut = int(round(t / max(1, fps) * sr))
w = segment_audio["waveform"]
segment_audio = {"waveform": w[..., cut:].contiguous(),
"sample_rate": sr}
if segment_audio is None:
segment_audio = {"waveform": torch.zeros(1, 2, 1),
"sample_rate": 32000}
return (segment, json.dumps(sp), segment_audio)
class H3WindowPass:
"""Identity on (IMAGE, AUDIO). Placed by H3 Window Expand when there is
nothing to regenerate or when plan_only is on, because an expansion is
the only way a node with raw-link inputs can hand its inputs back."""
DESCRIPTION = (
"EXPERIMENTAL (alpha), new 2026-08-12. Internal passthrough placed "
"by H3 Window Expand in plan-only mode and when the hold map has no "
"held frames.")
@classmethod
def INPUT_TYPES(cls):
return {"required": {"images": ("IMAGE",)},
"optional": {"audio": ("AUDIO",)}}
RETURN_TYPES = ("IMAGE", "AUDIO")
FUNCTION = "pass_through"
CATEGORY = "image/minimax/motion"
def pass_through(self, images, audio=None):
if audio is None:
audio = {"waveform": torch.zeros(1, 2, 1), "sample_rate": 32000}
return (images, audio)
# ---------------------------------------------------------------------------
# the driver
# ---------------------------------------------------------------------------
class H3WindowExpand:
"""Plan N windows, then expand into N regeneration chains and a chained
splice. One queue press, one node in the user's graph."""
DESCRIPTION = (
"EXPERIMENTAL (alpha), new 2026-08-12; the classic pipeline nodes "
"are unchanged.\n\n"
"Rolling-window regeneration for clips whose dilated length does "
"not fit in VRAM. Reads the hold map, splits the held material into "
"windows no larger than max_dilated_frames DILATED frames each, and "
"returns an expanded graph containing the whole segment recipe once "
"per window (crop, smear, encode, v2v init, sample, decode, "
"recover) plus a chained H3 Segment Splice that writes each "
"recovered window back over the world. Press queue once.\n\n"
"The budget is in DILATED frames because that, not the clip length, "
"is what sets the bill and the VRAM peak: read H3 Time Smear's "
"report on a full-clip pass and set max_dilated_frames below the "
"length that fits your card.\n\n"
"Seams: boundaries snap to cold cuts (hold 1 on both sides), then "
"to token boundaries, and never land inside a ramp shoulder. When "
"no cold cut fits the budget the plan says so in the report and "
"falls back to pin-and-trim: the next window's leading handle is "
"generated as context at the same hold rate, then discarded, and "
"the splice makes a hard cut instead of dissolving two takes of the "
"same fast motion.\n\n"
"Everything except the hold map is taken as a raw link and rewired "
"into the expansion, so the model, guider, sampler and VAE are "
"loaded once and shared by every window, and the per-window nodes "
"keep normal input-signature caching: requeue after changing one "
"widget and the windows whose inputs did not change are cache "
"hits. Read the report output before generating; plan_only returns "
"the baseline untouched with the plan attached.")
HOT = ["pin-and-trim (default)", "crossfade", "refuse"]
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"images": ("IMAGE", {"rawLink": True,
"tooltip": "baseline world frames"}),
"hold_map": ("STRING", {"default": "", "forceInput": True,
"tooltip": "from the oracle, H3 Manual Hold Map or H3 Motion Editor"}),
"vae": ("VAE", {"rawLink": True, "tooltip": "video VAE"}),
"noise": ("NOISE", {"rawLink": True}),
"guider": ("GUIDER", {"rawLink": True}),
"sampler": ("SAMPLER", {"rawLink": True}),
"sigmas": ("SIGMAS", {"rawLink": True,
"tooltip": "H3 Inject Schedule; every window uses it"}),
"max_dilated_frames": ("INT", {"default": 209, "min": 39, "max": 4000,
"tooltip": "budget per window, in DILATED frames"}),
"handle_frames": ("INT", {"default": 12, "min": 2, "max": 48}),
"feather_frames": ("INT", {"default": 6, "min": 0, "max": 24,
"tooltip": "crossfade width at COLD seams; hot seams are hard cuts"}),
}, "optional": {
"audio_vae": ("VAE", {"rawLink": True,
"tooltip": "audio VAE; unwired means video-only windows"}),
"baseline_audio": ("AUDIO", {"rawLink": True}),
"fps": ("INT", {"default": 24, "min": 1, "max": 120}),
"reference_mix": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05}),
"hot_cut": (cls.HOT, {"default": cls.HOT[0]}),
"snap_search": ("INT", {"default": 8, "min": 0, "max": 48,
"tooltip": "frames to look back for a token-aligned hot cut"}),
"max_windows": ("INT", {"default": 12, "min": 1, "max": 64}),
"grid_growth": ("BOOLEAN", {"default": True,
"tooltip": "grow a hold-1 handle to land on the 17k+5 grid "
"instead of letting the tail freeze absorb the remainder"}),
"plan_only": ("BOOLEAN", {"default": False}),
"s_per_step": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 600.0, "step": 0.05}),
"est_steps": ("INT", {"default": 18, "min": 1, "max": 100}),
}}
RETURN_TYPES = ("IMAGE", "AUDIO", "STRING")
RETURN_NAMES = ("images", "audio", "report")
FUNCTION = "expand"
CATEGORY = "image/minimax/motion"
# ---- planning is separated from graph building so it can be tested
# without a running server (see agent-window-expand.md)
def build_plan(self, hold_map, max_dilated_frames, handle_frames,
feather_frames, hot_cut="pin-and-trim (default)",
snap_search=8, max_windows=12, grid_growth=True,
fps=24, s_per_step=0.0, est_steps=18):
holds = json.loads(hold_map)["holds"]
n = len(holds)
windows, notes = plan_windows(holds, int(max_dilated_frames),
int(handle_frames), int(snap_search),
int(max_windows), bool(grid_growth))
specs = []
for w in windows:
hot_in = w["seam_in"] == "hot"
hot_out = w["seam_out"] == "hot"
if hot_in and hot_cut == "refuse":
notes.append(f"window {w['k']}: hot seam and hot_cut=refuse; "
f"raise max_dilated_frames or lower d_max")
fade_in = 0 if hot_in else min(int(feather_frames), w["handle_in"])
fade_out = 0 if hot_out else min(int(feather_frames),
w["handle_out"])
if hot_in and hot_cut == "crossfade":
fade_in = min(int(feather_frames), w["handle_in"])
trim = w["handle_in"] if (hot_in and
hot_cut != "crossfade") else 0
specs.append({"k": w["k"], "a": w["a"], "b": w["b"],
"body_a": w["body_a"], "body_b": w["body_b"],
"world_len": n, "holds": w["holds"],
"handle_in": w["handle_in"],
"handle_out": w["handle_out"],
"dilated": w["dilated"], "tokens": w["tokens"],
"seam_in": w["seam_in"], "seam_out": w["seam_out"],
"fade_in": fade_in, "fade_out": fade_out,
"trim_head": trim})
report = plan_report(windows, notes, holds, int(max_dilated_frames),
fps, s_per_step, est_steps)
return specs, report
def build_graph(self, specs, images, vae, noise, guider, sampler, sigmas,
audio_vae=None, baseline_audio=None, fps=24,
reference_mix=0.0, plan_only=False):
"""Return (GraphBuilder, image_link, audio_link).
`images`, `vae`, ... are raw links (["node_id", slot]) into the
user's graph, so the loaders and the conditioning stay outside the
expansion and are shared by every window.
"""
from comfy_execution.graph_utils import GraphBuilder
g = GraphBuilder()
def node(cls, nid, **kw):
# An unwired optional stays absent rather than becoming a null
# input: expanded nodes are never validated, so a null would only
# surface later as a TypeError inside the body.
return g.node(cls, nid, **{k: v for k, v in kw.items()
if v is not None})
if plan_only or not specs:
p = node("H3WindowPass", "pass", images=images,
audio=baseline_audio)
return g, p.out(0), p.out(1)
world, world_audio = images, baseline_audio
for s in specs:
k = s["k"]
# A hot seam has to read its leading handle from the window that
# already ran, so pin-and-trim pins onto regenerated frames. A
# cold seam reads the pristine baseline, which lets its crop run
# before the previous window finishes and keeps one fewer
# full-world tensor alive.
src = world if s["seam_in"] == "hot" else images
crop = node("H3WindowCrop", f"w{k}_crop", images=src,
window_spec=json.dumps(
{q: s[q] for q in CROP_SPEC_KEYS}))
smear = node("H3TimeSmear", f"w{k}_smear", images=crop.out(0),
dilation=1, hold_map=crop.out(1), fps=fps)
enc = node("VAEEncode", f"w{k}_enc", pixels=smear.out(0), vae=vae)
init = node("H3V2VInit", f"w{k}_init", samples=enc.out(0),
length=0)
samp = node("SamplerCustomAdvanced", f"w{k}_sampler", noise=noise,
guider=guider, sampler=sampler, sigmas=sigmas,
latent_image=init.out(0))
dec = node("VAEDecode", f"w{k}_dec", samples=samp.out(0), vae=vae)
rec = node("H3ExactRecover", f"w{k}_rec", images=dec.out(0),
hold_map=smear.out(1))
seg_audio = None
if audio_vae is not None:
deca = node("VAEDecodeAudio", f"w{k}_deca",
samples=samp.out(0), vae=audio_vae)
arec = node("H3AudioRecover", f"w{k}_arec", audio=deca.out(0),
hold_map=smear.out(1), fps=fps,
reference=world_audio,
reference_mix=reference_mix)
seg_audio = arec.out(0)
seam = node("H3WindowSeam", f"w{k}_seam", segment=rec.out(0),
splice_map=crop.out(2), trim_head=s["trim_head"],
fade_in=s["fade_in"], fade_out=s["fade_out"],
segment_audio=seg_audio, fps=fps)
spl = node("H3SegmentSplice", f"w{k}_splice", baseline=world,
segment=seam.out(0), splice_map=seam.out(1),
feather_frames=24, baseline_audio=world_audio,
segment_audio=seam.out(2) if seg_audio else None,
fps=fps)
world, world_audio = spl.out(0), spl.out(1)
return g, world, world_audio
def expand(self, images, hold_map, vae, noise, guider, sampler, sigmas,
max_dilated_frames, handle_frames, feather_frames,
audio_vae=None, baseline_audio=None, fps=24, reference_mix=0.0,
hot_cut="pin-and-trim (default)", snap_search=8,
max_windows=12, grid_growth=True, plan_only=False,
s_per_step=0.0, est_steps=18):
specs, report = self.build_plan(
hold_map, max_dilated_frames, handle_frames, feather_frames,
hot_cut, snap_search, max_windows, grid_growth, fps, s_per_step,
est_steps)
g, img, aud = self.build_graph(
specs, images, vae, noise, guider, sampler, sigmas, audio_vae,
baseline_audio, fps, reference_mix, plan_only)
return {"result": (img, aud, report), "expand": g.finalize()}
NODE_CLASS_MAPPINGS = {
"H3WindowExpand": H3WindowExpand,
"H3WindowCrop": H3WindowCrop,
"H3WindowSeam": H3WindowSeam,
"H3WindowPass": H3WindowPass,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"H3WindowExpand": "H3 Window Expand (rolling regen)",
"H3WindowCrop": "H3 Window Crop (internal)",
"H3WindowSeam": "H3 Window Seam (internal)",
"H3WindowPass": "H3 Window Pass (internal)",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS",
"plan_windows", "plan_report"]