Files
malkuthro-ComfyUI-Koolook/k_ltx_av_bind_schedule.py
ba37ec3d41 feat: LTX Director 2.0.2 fork, keyframe grid-snap, and audio→video bind schedule (#259)
* feat(forks): LTX Director v2_0_2 fork + keyframe latent-grid snap (#258)

Bump the WhatDreamsCost Koolook fork to upstream 2.0.2 (pinned fe09f73) in a
new versions/v2_0_2 namespace, and add `snap_keyframes_to_grid` (default on)
to fix hard-pinned image keyframes "jumping" at latent-bucket edges.

Why: a Director image keyframe is injected as a single latent frame at the
segment's left edge, quantized to the model temporal stride s
(L(f)=0 if f==0 else 1+(f-1)//s). Pins near a bucket boundary round
ambiguously and two pins in one bucket collide. Snapping each pin to its
bucket center removes the ambiguity without changing which latent frame it
lands on; colliding pins are bumped to the next free bucket with a warning.

- forks/whatdreamscost_koolook/versions/v2_0_2/: vendored 2.0.2 sources with
  the v1.3.9 Koolook deltas carried forward (relay_overrides,
  audio_transcript_json, per-segment sigma — build_segments is byte-identical
  1.3.9->2.0.2 so the sigma patch drops in verbatim), plus new pure
  keyframe_grid.py snapper wired into the insert_frames build.
- Canonical LTXDirector__koolook rolls to 2.0.2 (entrypoint loads v2_0_2
  last); LTXDirector__koolook_v1_3_2 alias preserved; v1.3.9 kept on disk.
- LICENSE re-verified GPL-3.0 (2026-06-22). Manifest + THIRD_PARTY + CHANGELOG
  + audio-lipsync findings/CHEATSHEET updated.
- 37 new unit tests for the snapper; 107 fork tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(forks): append Koolook LTX Director widgets last for clean load

Move the v2_0_2 Koolook widgets (relay_overrides, audio_transcript_json,
snap_keyframes_to_grid) to the end of the schema, after override_audio. They
were inserted after img_compression, which shifted the last upstream widget
when an upstream-2.0.x-saved workflow is loaded onto LTXDirector__koolook.
With them last, an upstream graph's positional widgets_values map onto the
upstream widgets untouched and only the Koolook widgets append with defaults
(snap_keyframes_to_grid=on). Execution is unaffected (kwargs by name).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(forks): make v2_0_2 a clean upstream-2.0.2 replica + grid-snap only

Per maintainer direction: the v2_0_2 Koolook LTX Director should faithfully
replicate upstream 2.0.2 (so all latest features work) rather than carry the
older customizations, which are unused.

- Drop relay_overrides, audio_transcript_json, and the per-segment sigma patch.
  prompt_relay.py and patches.py are now vendored VERBATIM from upstream 2.0.2;
  _relay_overrides.py removed.
- ltx_director.py is upstream 2.0.2 verbatim except two additive changes:
  the namespaced node ID LTXDirector__koolook, and the snap_keyframes_to_grid
  latent-bucket keyframe snap (issue #258), appended last in the schema.
- v2_0_2 registers only LTXDirector__koolook; the legacy
  LTXDirector__koolook_v1_3_2 ID stays backed by the v1_3_9 package. v1.3.9
  (with the older customizations) remains on disk for rollback.
- Manifest / THIRD_PARTY / UPSTREAM_PIN / CHANGELOG updated to the clean scope.
  grid-snap unit tests unchanged (107 fork tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(forks): vendor upstream 2.0.2 timeline editor JS for LTXDirector__koolook

The v2_0_2 Python was upstream-exact but the node still rendered with the old
1.3.9-era timeline editor (fewer toolbar actions, no IC-LoRA track, leaked
*_second widgets, frame_rate NaN). Replace web/whatdreamscost_koolook/
ltx_director.js with upstream 2.0.2's js/ltx_director.js vendored verbatim,
retargeted with only two edits: unique extension name 'LTXDirector_Koolook'
and node guard nodeData.name === 'LTXDirector__koolook'. UX now matches
upstream 2.0.2 exactly; only the Python differs (grid-snap).

The old koolook base64-scrub deltas are dropped as obsolete — upstream 2.0.2
persists timeline media via imageFile/audioFile + /api/view URLs (saved
timelines are already lean), and the localStorage quota guard stays in the
global web/koolook_draft_guard.js (upstream does not override localStorage).

Rewrote tests/sidebar/test_ltx_director_draft_guard.py to the new contract
(no embedded guard, retargeted to the koolook node, pinned-upstream
provenance). 110 fork+contract tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scripts): resolve .env from main repo when dev-sync runs in a worktree

Unrelated to the LTX work: plain `dev-sync` failed with "KOLOOK_COMFYUI_DEV_PATH
not set" from any worktree because scripts/sync_to_dev.py only loaded
`.env` from the worktree root, where the committed `.env` does not exist
(it lives in the main checkout). sync_to_dev_audio.py already had the
worktree->main fallback, which is why dev-sync-audio worked.

Move that fallback into a shared sync_to_dev.find_dotenv() and use it in both
scripts so they can't drift. Adds regression tests for the worktree fallback,
the worktree-own-.env preference, and the not-a-worktree case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: gitignore root-level generated workflow JSONs

Node-swapped/converted workflow copies (e.g. *.koolook.json) are dropped at
the repo root during dev iteration; they're scratch deliverables, not package
content. Ignore root-level *.json only (leading `/`), with a `!config.json`
exception, so the 30+ tracked JSON in subdirs (test fixtures, web/, .claude/,
docs/runs/*/workflow.json) stay tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: LTX A/V Bind Schedule node — gate audio→video binding by sigma

New Koolook node LTXAVBindSchedule decouples big motion from lip-sync in LTX
2.3. The AV transformer (comfy/ldm/lightricks/av_model.py, BasicAVTransformerBlock)
adds audio into video via a gated `audio_to_video_attn` per block, run only when
transformer_options["a2v_cross_attn"] is set. This node patches each block's
audio_to_video_attn.forward to scale its output by a per-step gain, and a
unet-function-wrapper computes that gain from the current sigma: early/high-sigma
steps run near `early_gain` (coarse motion settles audio-blind), ramping
(smoothstep) to full at low sigma (lips bind onto settled motion). One pass, no
re-noise — the fix for keyframe transitions jumping on audio peaks.

early_gain=1.0 reproduces stock behavior; a graph without the node is unchanged.
Registered as a root node + added to RUNTIME_PATHS so dev-sync ships it. Pure
ramp (a2v_gain) unit-tested (9 cases); model wiring validated by live render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: LTX A/V Bind Schedule — add transition-window mode (lips synced + smooth)

The global sigma ramp decoupled motion from lips but desynced the mouth (lip
timing is coarse motion too, so holding audio out globally moved the mouth
audio-blind). Add a WINDOWED mode: audio binds at full strength everywhere
(lips sync across the whole clip) EXCEPT a +/-transition_window band around each
keyframe transition, where the sigma ramp applies — so only those moments are
motion-protected early and rejoined late.

Implemented as a per-latent-frame gain on a2v_out: read grid_sizes from
transformer_options to map frame-major video tokens -> latent frames, build a
gain vector (1.0 outside bands, smoothstep-tapered to sigma_gain at each
transition center), expand per-frame -> per-token, scale. New optional inputs:
transition_frames, total_frames, transition_window. Empty transition_frames =
prior global-ramp behavior. windowed_frame_gains unit-tested (7 cases).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: LTXAVBindSchedule — one-shot diagnostic log (shapes/grid/mode)

Logs out.shape, grid_sizes, transition_fracs, and whether the per-frame mask
fired vs fell back to scalar — so a single render reveals whether the a2v patch
hooks the model and whether the windowed token->frame mapping aligns, instead
of guessing blind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(audio-lipsync): record validated a2v bind-schedule knee (0.35/0.25/0.55)

Promote the finding that LTX 2.3's audio->video coupling must be sigma-ramped
(not full-on) to keep motion smooth AND lips synced, with the validated knee
early_gain=0.35 / bind_start=0.25 / bind_end=0.55 on the 8-step base pass, plus
the trade-axis table and the per-frame-mode caveat (grid_sizes None + appended
guide frames -> scalar fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(forks): keyframe ease — strength-ramped neighbor pins to de-robotic transitions

Adds `keyframe_ease` / `ease_falloff` to the Koolook LTX Director (default off).
Each hard image pin keeps its exact center pose, but emits same-pose neighbor
pins one latent bucket apart at decreasing strength (center*falloff**k), so the
model glides into and out of the locked pose instead of snapping to a dead stop
— softening the "stop/dissolve/stop" robotic feel without softening the pose.

Implemented as a pure list expansion (`expand_keyframe_ease` in keyframe_grid.py,
6 unit tests) applied to guide_data after grid-snap, so the upstream guide node
is untouched (no fork). Center stays exact; neighbors that fall off-clip or onto
another center are skipped. Costs extra guide frames (more compute), hence opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(audio-lipsync): downgrade a2v knee to 'promising' — note lows-placement confound

The 0.35/0.25/0.55 win was measured on a clip whose transitions sit on audio
lows, so 'no jumps' may be the placement, not the node. Mark the controlled
on-peak test (early_gain 1.0 vs 0.35 on the same on-peak layout) as still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: mark vendored WhatDreamsCost fork files linguist-vendored

The koolook Director ships upstream 2.0.2 code (the 8.5k-line timeline-editor JS
+ verbatim prompt_relay/patches) so the pack runs and distributes, but it's
upstream, not authored here. Mark those linguist-vendored (drop from language
stats) and the JS -diff (collapse the large blob in PR diffs). Our edited/new
files stay attributed to us.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ltx-av): drop unused `import math` (ruff F401)

a2v_gain uses an explicit smoothstep polynomial, not math.*, so the
import was dead. Unblocks the Lint & Smoke CI gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(codeql): advanced setup that excludes vendored upstream code

CodeQL default setup cannot read a config file, so it scanned the
verbatim-vendored WhatDreamsCost editor (web/whatdreamscost_koolook/
ltx_director.js) and raised 4 high-sev DOM-XSS alerts in upstream code we
do not own. Switch to advanced setup with a committed config whose
paths-ignore mirrors the .gitattributes linguist-vendored block, so the
scanner treats vendored upstream as out-of-scope while still fully
scanning our own nodes and sidebar JS.

Languages match the prior default-setup matrix (actions,
javascript-typescript, python).

NOTE: requires disabling "default setup" once in repo Settings before
this workflow can run (GitHub rejects advanced runs while default setup
is enabled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ci): scope vendored fork exclusions to v2_0_2

* fix(forks): preserve final LTX keyframe snap bucket

---------

Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:15:41 +02:00

298 lines
12 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
#
# ComfyUI-Koolook — LTX A/V Bind Schedule node.
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
"""LTX A/V Bind Schedule (Koolook) — decouple big motion from lip-sync.
LTX 2.3's audio-video transformer (`comfy/ldm/lightricks/av_model.py`,
`BasicAVTransformerBlock`) injects audio into the video stream through a single
gated cross-attention per block::
a2v_out = self.audio_to_video_attn(vx_scaled, context=ax_scaled, ...)
vx.addcmul_(a2v_out, gate_out_a2v)
That audio->video term makes the lips follow the audio — and also fights a hard
keyframe pose change when a transition lands on an audio peak (the jump). This
node scales ``a2v_out`` by a **gain** so we control where/when audio binds.
Two modes (selected by whether ``transition_frames`` is set):
* **Global ramp** (``transition_frames`` empty): gain ramps over the denoise
from ``early_gain`` (high sigma — motion settles audio-blind) up to 1.0 (low
sigma — lips bind). Decouples motion from lips, but lip-sync timing is also
coarse motion, so holding audio out globally desyncs the mouth.
* **Transition windows** (``transition_frames`` set, e.g. "34,63,93" with
``total_frames``): audio binds at FULL strength everywhere (lips sync across
the whole clip) EXCEPT in a ±``transition_window`` band around each keyframe
transition, where the sigma ramp applies — so only those moments are
motion-protected early and rejoined late. This breaks the motion/lip-sync
trade-off: smooth transitions AND synced lips.
``early_gain=1.0`` (or no node) reproduces stock behavior.
NOTE: the pure ramp (`a2v_gain`) and per-frame mask (`windowed_frame_gains`) are
unit-tested; the model wiring is validated by rendering on a machine with the
LTX 2.3 AV model loaded (not in CI).
"""
from __future__ import annotations
import types
import logging
log = logging.getLogger(__name__)
_GAIN_KEY = "nghtdrp_a2v_gain" # per-step sigma-ramp gain, set by the unet wrapper
def a2v_gain(sigma, sigma_max, bind_start=0.55, bind_end=0.75, early_gain=0.0):
"""Audio->video gain for the current ``sigma`` (the sigma ramp).
Progress runs 0 at the start of denoise (``sigma == sigma_max``) to 1 at the
end (``sigma -> 0``). Holds at ``early_gain`` until ``bind_start``, smoothsteps
to 1.0 by ``bind_end`` (progress fractions in [0, 1]).
"""
if sigma_max <= 0.0:
return 1.0
progress = 1.0 - (float(sigma) / float(sigma_max))
progress = 0.0 if progress < 0.0 else (1.0 if progress > 1.0 else progress)
if bind_end <= bind_start:
return 1.0 if progress >= bind_end else early_gain
if progress <= bind_start:
return early_gain
if progress >= bind_end:
return 1.0
t = (progress - bind_start) / (bind_end - bind_start)
s = t * t * (3.0 - 2.0 * t) # smoothstep
return early_gain + (1.0 - early_gain) * s
def windowed_frame_gains(latent_frames, transition_idxs, window, sigma_gain):
"""Per-latent-frame a2v gain list.
1.0 everywhere (full audio -> lips sync), dipping toward ``sigma_gain`` inside
a ``+/-window`` band around each transition latent index, tapered smoothly so
there's no hard edge. At a transition's center the gain is ``sigma_gain`` (so
the sigma ramp still protects motion early / rejoins late there); at the band
edge it's back to 1.0.
"""
if latent_frames <= 0:
return []
window = max(0, int(window))
gains = [1.0] * latent_frames
if not transition_idxs:
# no windows -> uniform sigma_gain (degenerates to the global ramp)
return [float(sigma_gain)] * latent_frames
for f in range(latent_frames):
best = 1.0
for ti in transition_idxs:
dist = abs(f - int(ti))
if dist <= window:
if window == 0:
taper = 0.0
else:
u = dist / window # 0 center -> 1 edge
taper = u * u * (3.0 - 2.0 * u) # smoothstep
gv = sigma_gain + (1.0 - sigma_gain) * taper
if gv < best:
best = gv
gains[f] = best
return gains
def _parse_frames(s):
out = []
for tok in str(s or "").replace(";", ",").split(","):
tok = tok.strip()
if not tok:
continue
try:
out.append(float(tok))
except ValueError:
pass
return out
def _make_a2v_scale_method(underlying, transition_fracs, window_frac, diag):
"""Bound-method wrapper around audio_to_video_attn.forward: scale its output
by the per-step (and optionally per-frame) gain. ``diag`` is a shared 1-elem
list used to log geometry once per render."""
def wrapped(_self_module, *args, **kwargs):
out = underlying(*args, **kwargs)
to = kwargs.get("transformer_options", {}) or {}
sigma_gain = float(to.get(_GAIN_KEY, 1.0))
grid = to.get("grid_sizes", None)
def _diag(applied):
if not diag[0]:
diag[0] = True
log.info(
"[LTXAVBindSchedule] a2v fired: out.shape=%s grid_sizes=%s "
"transition_fracs=%s window_frac=%.4f -> %s",
tuple(getattr(out, "shape", ())), grid, transition_fracs,
window_frac, applied,
)
# Global mode (no transition windows): uniform scalar gain.
if not transition_fracs:
_diag("global scalar")
return out if sigma_gain == 1.0 else out * sigma_gain
# Windowed mode: need the latent grid to map tokens -> frames.
try:
latent_frames = int(grid[0])
tpf = int(grid[1]) * int(grid[2])
except Exception:
_diag("FALLBACK scalar (no grid_sizes)")
return out if sigma_gain == 1.0 else out * sigma_gain
if latent_frames <= 0 or tpf <= 0 or out.shape[1] != latent_frames * tpf:
_diag(f"FALLBACK scalar (shape mismatch: out[1]={out.shape[1]} vs "
f"{latent_frames}*{tpf}={latent_frames * tpf})")
return out if sigma_gain == 1.0 else out * sigma_gain
_diag(f"per-frame (latent_frames={latent_frames}, tpf={tpf})")
ti = sorted({max(0, min(latent_frames - 1, round(fr * (latent_frames - 1))))
for fr in transition_fracs})
wl = max(1, round(window_frac * latent_frames))
fg = windowed_frame_gains(latent_frames, ti, wl, sigma_gain)
if all(g == 1.0 for g in fg):
return out
# expand per-frame -> per-token (frame-major) and scale
gain_tok = out.new_tensor(fg).repeat_interleave(tpf).view(1, -1, 1)
return out * gain_tok
return wrapped
class LTXAVBindSchedule:
"""Schedule LTX 2.3 audio->video binding across denoise and/or in time."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL", {"tooltip": "LTX 2.3 audio-video model."}),
"early_gain": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "Audio->video strength in the early (high-sigma) steps "
"where big motion resolves. 0 = audio-blind motion; "
"1 = stock. In windowed mode this is the floor INSIDE "
"the transition bands only.",
}),
"bind_start": ("FLOAT", {
"default": 0.55, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "Denoise progress (0=start,1=end) where audio begins "
"ramping in.",
}),
"bind_end": ("FLOAT", {
"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "Denoise progress where audio binding reaches full.",
}),
},
"optional": {
"transition_frames": ("STRING", {
"default": "",
"tooltip": "Comma-separated keyframe transition frames (timeline "
"frames, e.g. '34,63,93'). Empty = global ramp (audio "
"gated everywhere). Set = WINDOWED: audio binds fully "
"everywhere except a band around these frames.",
}),
"total_frames": ("INT", {
"default": 0, "min": 0, "max": 100000, "step": 1,
"tooltip": "Clip length in timeline frames (e.g. 146). Required "
"for windowed mode to map frames -> latent grid.",
}),
"transition_window": ("INT", {
"default": 8, "min": 0, "max": 100000, "step": 1,
"tooltip": "Half-width (timeline frames) of the audio-protected "
"band around each transition.",
}),
},
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "patch"
CATEGORY = "Koolook/LTX"
DESCRIPTION = (
"Decouple big motion from lip-sync in LTX 2.3 by gating the audio->video "
"cross-attention — globally by sigma, or (windowed) only around keyframe "
"transitions so lips stay synced everywhere else. One pass, no re-noise."
)
def patch(self, model, early_gain, bind_start, bind_end,
transition_frames="", total_frames=0, transition_window=8):
m = model.clone()
try:
sigma_max = float(m.get_model_object("model_sampling").sigma_max)
except Exception as e: # pragma: no cover - live model
log.warning("[LTXAVBindSchedule] no model_sampling.sigma_max: %s", e)
sigma_max = 1.0
# windowed-mode geometry (fractions are stride-independent)
frames = _parse_frames(transition_frames)
if frames and total_frames and total_frames > 0:
transition_fracs = [min(1.0, max(0.0, f / float(total_frames))) for f in frames]
window_frac = max(0.0, transition_window / float(total_frames))
mode = f"windowed ({len(transition_fracs)} transitions, +/-{transition_window}f)"
else:
transition_fracs = []
window_frac = 0.0
mode = "global ramp"
patched = 0
diag = [False] # shared one-shot diagnostic flag across blocks
try:
dm = m.get_model_object("diffusion_model")
for idx, block in enumerate(getattr(dm, "transformer_blocks", None) or []):
if getattr(block, "audio_to_video_attn", None) is None:
continue
key = f"diffusion_model.transformer_blocks.{idx}.audio_to_video_attn.forward"
underlying = m.get_model_object(key)
wrapper = _make_a2v_scale_method(underlying, transition_fracs, window_frac, diag)
m.add_object_patch(key, types.MethodType(wrapper, block.audio_to_video_attn))
patched += 1
except Exception as e: # pragma: no cover - live model
log.warning("[LTXAVBindSchedule] block patch failed: %s", e)
if patched == 0:
log.warning(
"[LTXAVBindSchedule] no audio_to_video_attn blocks found — is this an "
"LTX 2.3 audio-video model? Model passed through unchanged."
)
return (m,)
prev = m.model_options.get("model_function_wrapper")
def unet_wrapper(model_function, params):
try:
sigma = float(params["timestep"].max())
except Exception:
sigma = sigma_max
gain = a2v_gain(sigma, sigma_max, bind_start, bind_end, early_gain)
c = params["c"]
to = dict(c.get("transformer_options", {}))
to[_GAIN_KEY] = gain
c = {**c, "transformer_options": to}
params = {**params, "c": c}
if prev is not None:
return prev(model_function, params)
return model_function(params["input"], params["timestep"], **params["c"])
m.set_model_unet_function_wrapper(unet_wrapper)
log.info("[LTXAVBindSchedule] patched %d a2v blocks; mode=%s; early_gain=%.2f "
"bind=%.2f..%.2f", patched, mode, early_gain, bind_start, bind_end)
return (m,)
NODE_CLASS_MAPPINGS = {"LTXAVBindSchedule": LTXAVBindSchedule}
NODE_DISPLAY_NAME_MAPPINGS = {"LTXAVBindSchedule": "LTX A/V Bind Schedule (Koolook)"}
__all__ = [
"NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS",
"a2v_gain", "windowed_frame_gains",
]