Files
malkuthro-ComfyUI-Koolook/k_ltx_reference_bind_schedule.py
T
961a95b9bd feat(ltx-director): reference-image identity — Ghost Mask + schedulable guide attention (#264)
* feat(ltx-director): Ghost Mask reference image (REF / character sheet)

Add a no-LoRA reference-image identity anchor to LTXDirector__koolook, the
way a WAN VACE reference works: attach reference image(s) and the model keeps
the character's face / mouth consistent even when keyframes show a closed (or
different) mouth than the audio-driven motion needs. Targets the mouth /
identity drift seen in the talking-goat lip-sync workflow.

Mechanism (Ghost Mask): the reference image(s) are appended as masked guide
frames *after* the clean video region, so the transformer attends to them for
identity but they never appear in the output; the trailing reference frames
are then sliced off the result.

- LTXDirector__koolook: new optional `reference_images` (IMAGE) +
  `reference_strength` inputs, and appended `clean_latent_frames` /
  `clean_pixel_frames` outputs. All additive/optional -> saved workflows
  unaffected. Refs are appended AFTER snap/ease so they are never grid-snapped
  or ease-expanded; the auto latent is grown to hold them. ltx_director.py is
  under ruff's forks/ exclude.
- reference_ghost.py (new, Koolook-original, pure stdlib): trailing-reference
  geometry (insert frames, grown latent length, slice bounds). Unit-tested.
- k_clean_latent_slice.py (new root node `CleanLatentSlice`): torch.narrow
  slice to drop the trailing reference frames (start=0,
  length=clean_latent_frames). torch imported lazily; clamp math unit-tested,
  slice path tested when real torch is present (skips under the CI stub).
- Registered the node; added to sync_to_dev RUNTIME_PATHS.
- Attribution: the Ghost Mask APPROACH is adapted (idea only, reimplemented)
  from CGlide/WhatDreamsCost-CSGlide (main_cs @ 21dd076, GPL-3.0, license
  verified 2026-06-24, GPL->GPL OK). THIRD_PARTY.md, forks_manifest.yaml,
  UPSTREAM_PIN.yaml, CHANGELOG updated.

Phase 1: Ghost Mask only. The LoRA-backed Licon MSR prefix mode and the
editor's character-slot UI are intentionally NOT ported.

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

* feat(sidebar): action-led star footer ("Give a ★ on GitHub")

Reword the GitHub discovery footer from the passive "Help creators find
us on GitHub" to an action-led "Help creators find us! Give a ★ on
GitHub", with the star as the explicit ask. Render the sentence as one
inline text flow (not separate flex children) so the footer's `gap`
doesn't space out the words; the ★ glyph keeps its gold styling inline and
now carries role="img"/aria-label="star" so it is announced. Verified
rendering in an isolated harness.

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

* fix(ltx-director): Ghost Mask broke motion in the append-and-crop pipeline

The upstream guide pipeline (LTXDirectorGuide + LTXDirectorCropGuides)
appends every guide_data image as an extra latent frame and crops them
back off via `exact_crop_frames = latent.shape[2] - initial_latent_length`.
The Ghost Mask code (modeled on CGlide's CleanLatentSlice flow) ALSO
pre-grew the video latent by n_refs, so initial_latent_length became
clean+n_refs: CropGuides then left n_refs frames uncropped AND the audio
latent stayed at the clean length, desyncing the A/V joint transformer and
breaking motion.

Fix: don't pre-grow the latent. References are added to guide_data exactly
like timeline keyframes and ride the same append-and-crop path — the guide
node appends them and CropGuides removes them, audio untouched. Auto latent
is sized to the clean length again. CleanLatentSlice/clean_latent_frames
remain available for bare pipelines that don't use LTXDirectorCropGuides.

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

* feat(ltx): LTX Guide Reference Strength — per-stage reference boost

Adds a small Koolook node that rewrites the strength of ONLY the Ghost
Mask reference frames in guide_data (the trailing entries the Director now
tags with `reference_count`), leaving the keyframe pins untouched. Insert
it before a stage-2 refinement guide and set ~0.9 to lock identity during
the low-denoise pass without disturbing the motion set in stage 1 — the
deterministic form of "more reference in stage 2".

Chosen over vendoring the 600-line upstream LTXDirectorGuide (which would
force a node-ID rename + graph rewire): this is one small node inserted on
the stage-2 guide_data wire. The sigma-ramp alternative (ramp reference up
at low sigma) is deferred — the reference hook is noise_mask + a core-model
attention term, so it needs a live-model spike, not a clean static patch.

- Director tags guide_data["reference_count"] = n_refs.
- k_ltx_guide_reference_strength.py: pure strength rewrite (absolute /
  multiply, clamped), unit-tested; registered; added to sync paths.

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

* feat(ltx): LTX Reference Bind Schedule — sigma-ramp the reference attention

Confirmed against core (comfy/ldm/lightricks/model.py): the reference's
guide attention strength is turned into a log-space additive self-attention
bias in `_build_guide_self_attention_mask`, which is rebuilt EVERY denoise
step and explicitly supports strength > 1.0 to amplify. That's a clean
per-step hook — so the "inverse sigma curve" idea is buildable, not just a
stage knob.

New node mirrors LTXAVBindSchedule: a model_function_wrapper captures the
per-step sigma and stashes a gain in transformer_options; an object-patch on
diffusion_model._build_guide_self_attention_mask multiplies the strength of
ONLY the trailing `num_references` guide entries (references are appended
after keyframes) by a smoothstep ramp — neutral early (motion forms), rising
to peak_strength late (identity locks during refinement). Keyframe pins and
noise-mask freezing untouched; orthogonal to reference_strength. One pass, no
re-noise, no LoRA.

- ref_gain ramp + _boosted_entries unit-tested (11 tests); model wiring
  validated by live render.
- Registered; added to sync paths.

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

* fix(ltx): mark Reference Bind Schedule inert without an IC-LoRA

Live diagnostic on the AV model showed the builder is reached and the sigma
gain flows in, but resolved_guide_entries=None despite 3276 guide tokens.
Root cause: LTXDirectorGuide only records guide_attention_entries when an
IC-LoRA is active (`if is_lora_active:` gates _append_guide_attention_entry).
With no LoRA there is no per-guide attention bias to scale, so the
attention-ramp lever does not exist in the Ghost Mask path — it can only
attenuate/amplify entries that an IC-LoRA created.

Rather than silently no-op, the node now logs a clear INERT warning when the
entry list is empty, and the docstring/DESCRIPTION/CHANGELOG state the
IC-LoRA requirement. The node remains correct for the IC-LoRA path (its
partner). No-LoRA reference strengthening stays on the noise-mask lever
(reference_strength / LTX Guide Reference Strength).

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

* feat(ltx): Reference Bind Schedule works without an IC-LoRA (fabricates entries)

The attention-bias identity channel (_build_guide_self_attention_mask) is
base-model functionality, but LTXDirectorGuide only populates the per-guide
entries when an IC-LoRA is active, so the node was inert in the no-LoRA Ghost
Mask path. Now, when entries are missing, the node FABRICATES them for the
trailing reference tokens: it splits num_guide_tokens across
(num_keyframes + num_references) guide frames and assigns the sigma-ramped
strength to the reference share, then hands off to the core builder. This
gives the reference the same amplified-attention channel an IC-LoRA would use
— inside the Director, no guide-node vendor, no graph rewire (the node is
already in the user's setup). When an IC-LoRA IS active it scales the real
entries as before.

- New `fabricate_reference_entries(num_guide_tokens, num_references,
  num_keyframes, strength)` (pure, unit-tested) + `num_keyframes` input.
- Wrapper now does scale-or-fabricate; clearer one-shot diagnostics.

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

* fix(ltx): keep num_keyframes last so pre-existing RefBind widgets stay aligned

num_keyframes was inserted mid widget-order, which shifted ramp_start/ramp_end
for workflows saved before it existed. Move it to the end of the optional
inputs so saved [num_references, peak_strength, ramp_start, ramp_end] values
keep mapping correctly; num_keyframes just defaults to 0 on reload.

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

* fix(test): drop unused pytest import (ruff F401)

The guide-reference-strength test uses plain asserts; the stray
`import pytest` tripped ruff F401 and reddened the Lint & Smoke CI job.

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

* fix(ltx-director): default keyframe guidance 0.8 (smooth), not 1.0

The timeline editor auto-built the guide_strength string from each clip's
per-segment guideStrength, filling 1.0 for any clip left unset (and the
Python fallback matched). 1.0 is a hard pin, so a clip you forgot to lower
snapped instead of transitioning smoothly. Unset clips now default to 0.8
in the editor (display + serialization) and the node; explicit per-clip
values still override.

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

* feat(ltx): Keyframe Soften Schedule — smooth motion early, sharp poses late

New model-patcher node LTXKeyframeSoftenSchedule: sigma-schedules the LTX
keyframe denoise-mask pins so the model forms smooth motion between poses
early (pins loosened at high sigma) and resolves sharp poses late (pins
restored by a crossover). Uses the sampler's per-step denoise_mask_function
hook — no IC-LoRA required — and adds no guide frames (so it can't fabricate
extra poses). Audio-safe: only the video half of the NestedTensor A/V mask is
softened; the audio mask is untouched, so lip-sync is unaffected.

Two normalized 0-1 widgets map onto the sensitive useful range: max_soften
(ease, ->0-0.20) and crossover (invention window, ->0-0.05). Pure soften_gain
ramp unit-tested (11 tests); model wiring render-validated.

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

* fix(review): chain denoise hook, optional_latent clean-length, audio test, CGlide manifest

Addresses PR #264 review findings:

- MEDIUM (Soften node): chain a pre-existing denoise_mask_function instead of
  clobbering it (mirrors the sibling LTX patchers' wrapper-chaining), so the
  node composes with anything else that sets that hook.
- HIGH #2 (Director): clean_pixel_frames/clean_latent_frames now derive from the
  ACTUAL optional_latent shape when one is connected, so Ghost-Mask reference
  geometry and the reported counts follow the real latent instead of the
  timeline duration (with a safe fallback + warning).
- LOW (Soften node): reconcile docstring (crossover is sigma-progress, maps to
  internal 0-0.05), one-time warn on the sigma-read fallback, and extract a
  testable soften_denoise_mask() helper.
- LOW: lock the audio-safety guarantee with NestedTensor-stub tests — video
  mask softened, audio mask untouched, part-ordering asserted.
- #6: add a formal forks_manifest.yaml entry for the CSGlide Ghost Mask
  idea-only adaptation (was prose-only in another entry's notes; THIRD_PARTY.md
  already carries the full attribution).

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

---------

Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:30:46 +02:00

287 lines
14 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
#
# ComfyUI-Koolook — LTX Reference Bind Schedule node.
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
"""LTX Reference Bind Schedule (Koolook) — ramp reference identity up at low sigma.
Companion to the LTX Director Ghost Mask reference. The reference image is added
as a guide frame; the core LTX transformer attends to it with a per-guide
**attention strength** (`guide_attention_entries[].strength`) which it turns into
a log-space additive self-attention bias, rebuilt **every denoise step** in
`comfy/ldm/lightricks/model.py::_build_guide_self_attention_mask`. That builder
explicitly supports `strength > 1.0` to *amplify* a guide's attention.
This node schedules that amplification over the denoise so identity locks late
without disturbing motion that forms early:
* **Early steps (high sigma)** — gain ~1.0: the reference attention is neutral,
big motion resolves freely.
* **Late steps (low sigma)** — gain ramps up to `peak_strength`: the refinement
conforms harder to the reference's identity (face / mouth).
It's the inverse of the motion curve, and the mirror of `LTXAVBindSchedule`
(which ramps audio->video up at low sigma for lip-sync). It touches ONLY the
attention bias of the trailing `num_references` guide entries (references are
appended after the keyframes) — the keyframe pins and the noise-mask freezing
are untouched.
WORKS WITHOUT AN IC-LoRA. The attention-bias machinery is BASE-model
functionality — `_build_guide_self_attention_mask` builds the mask from
`resolved_guide_entries` regardless of any LoRA. The catch is that
LTXDirectorGuide only *populates* those entries `if is_lora_active`, so in the
no-LoRA Ghost Mask path the list is empty. This node closes that gap: when the
entries are missing it **fabricates them itself** for the trailing reference
tokens (splitting `num_guide_tokens` across the `num_keyframes + num_references`
guide frames and assigning the ramped strength to the reference share). So the
reference gets the same amplified-attention channel an IC-LoRA would use, inside
the Director, with no LoRA, no guide-node vendor, and no graph change. When an
IC-LoRA *is* active and real entries exist, it scales those instead.
Mechanics (same as the A/V node): a `model_function_wrapper` captures the
current sigma each step and stashes the gain in `transformer_options`; an
object-patch on `_build_guide_self_attention_mask` reads it and fabricates (or
scales) the reference entries' `strength` before the mask is built.
NOTE: the `ref_gain` ramp is unit-tested; the model wiring is validated by
rendering against a live LTX 2.3 model (not in CI).
"""
from __future__ import annotations
import types
import logging
log = logging.getLogger(__name__)
_REF_GAIN_KEY = "nghtdrp_ref_gain" # per-step reference-attention multiplier
def ref_gain(sigma, sigma_max, ramp_start=0.5, ramp_end=0.9, peak=2.0):
"""Reference-attention multiplier for the current ``sigma``.
Progress runs 0 at the start of denoise (``sigma == sigma_max``) to 1 at the
end (``sigma -> 0``). Holds at 1.0 (neutral) until ``ramp_start``, then
smoothsteps up to ``peak`` by ``ramp_end`` (progress fractions in [0, 1]).
Returning 1.0 means "no change" — the mask builder then skips entirely.
"""
if sigma_max <= 0.0:
return float(peak)
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 ramp_end <= ramp_start:
return float(peak) if progress >= ramp_end else 1.0
if progress <= ramp_start:
return 1.0
if progress >= ramp_end:
return float(peak)
t = (progress - ramp_start) / (ramp_end - ramp_start)
s = t * t * (3.0 - 2.0 * t) # smoothstep
return 1.0 + (float(peak) - 1.0) * s
def _boosted_entries(resolved_entries, ref_count, gain):
"""Return a copy of ``resolved_entries`` with the LAST ``ref_count`` entries'
``strength`` multiplied by ``gain`` (references are appended after keyframes).
Returns the original list unchanged when there's nothing to do."""
if not resolved_entries or gain == 1.0 or ref_count <= 0:
return resolved_entries
n = len(resolved_entries)
k = min(int(ref_count), n)
if k <= 0:
return resolved_entries
out = list(resolved_entries)
for i in range(n - k, n):
e = dict(out[i])
e["strength"] = float(e.get("strength", 1.0)) * float(gain)
out[i] = e
return out
def fabricate_reference_entries(num_guide_tokens, num_references, num_keyframes, strength):
"""Build ``resolved_guide_entries`` for the trailing reference tokens when the
guide node created none (the no-IC-LoRA path).
Splits ``num_guide_tokens`` evenly across the ``num_keyframes + num_references``
guide frames and gives the trailing reference share ``strength`` while the
keyframe share stays 1.0 (neutral). The entries match the shape the core
builder consumes (``strength`` + ``surviving_count`` + optional pixel_mask /
latent_shape). When ``num_keyframes`` is 0 the whole guide region is treated
as reference. Returns ``[]`` when there's nothing to amplify.
"""
n_guide = int(num_guide_tokens)
n_ref = int(num_references)
n_kf = max(0, int(num_keyframes))
if n_guide <= 0 or n_ref <= 0:
return []
total_frames = n_ref + n_kf
ref_tokens = round(n_guide * n_ref / total_frames) if total_frames > 0 else n_guide
ref_tokens = max(1, min(int(ref_tokens), n_guide))
kf_tokens = n_guide - ref_tokens
entries = []
if kf_tokens > 0:
entries.append({"strength": 1.0, "surviving_count": kf_tokens, "pixel_mask": None, "latent_shape": None})
entries.append({"strength": float(strength), "surviving_count": ref_tokens, "pixel_mask": None, "latent_shape": None})
return entries
def _make_guide_mask_wrapper(underlying, ref_count, kf_count, peak, diag):
"""Bound-method wrapper around ``_build_guide_self_attention_mask``.
If the guide node already populated entries (IC-LoRA path) scale the trailing
``ref_count`` of them by the per-step gain. If not (no-LoRA Ghost Mask path)
fabricate entries for the trailing reference tokens at the gain strength. Then
hand off to the original builder, which turns it into the additive bias.
"""
def wrapped(_self_module, x, transformer_options, merged_args, *args, **kwargs):
to = transformer_options or {}
gain = float(to.get(_REF_GAIN_KEY, 1.0))
ma = merged_args if isinstance(merged_args, dict) else {}
entries = ma.get("resolved_guide_entries")
n_guide = int(ma.get("num_guide_tokens") or 0)
mode = "passthrough"
if entries: # IC-LoRA path — scale real entries
if gain != 1.0 and ref_count > 0:
boosted = _boosted_entries(entries, ref_count, gain)
if boosted is not entries:
merged_args = {**ma, "resolved_guide_entries": boosted}
mode = "scaled"
elif n_guide > 0 and ref_count > 0 and gain != 1.0: # no-LoRA path — fabricate
fab = fabricate_reference_entries(n_guide, ref_count, kf_count, gain)
if fab:
merged_args = {**ma, "resolved_guide_entries": fab}
mode = "fabricated"
if not diag.get("first"):
diag["first"] = True
log.info(
"[LTXReferenceBindSchedule] active: num_guide_tokens=%s entries=%s "
"ref_count=%d num_keyframes=%d peak=%.2f (amplifies the trailing "
"reference tokens at low sigma).",
n_guide, (len(entries) if entries else 0), int(ref_count),
int(kf_count), float(peak),
)
if mode in ("scaled", "fabricated") and not diag.get(mode):
diag[mode] = True
log.info("[LTXReferenceBindSchedule] %s reference attention (gain x%.2f).", mode, gain)
return underlying(x, transformer_options, merged_args, *args, **kwargs)
return wrapped
class LTXReferenceBindSchedule:
"""Ramp the LTX Ghost Mask reference's attention up as sigma falls."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL", {"tooltip": "LTX 2.3 model (the same one feeding the guide pipeline)."}),
"num_references": ("INT", {
"default": 1, "min": 0, "max": 64, "step": 1,
"tooltip": "How many trailing guide frames are references (= how many images you fed LTX Director's reference_images). They're appended after the keyframes, so the LAST N guide frames are the references.",
}),
"peak_strength": ("FLOAT", {
"default": 2.0, "min": 1.0, "max": 8.0, "step": 0.05,
"tooltip": "Reference attention multiplier at the end of denoise (low sigma). 1.0 = no boost; 2.0 = double the identity pull during refinement. Try 2-4.",
}),
},
"optional": {
# NOTE: ramp_start/ramp_end stay first to preserve widget order
# for workflows saved before num_keyframes existed; num_keyframes
# is appended last so old [num_references, peak_strength, ramp_start,
# ramp_end] values keep aligning.
"ramp_start": ("FLOAT", {
"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "Denoise progress (0=start, 1=end) where the reference boost begins. Keep above where big motion finalizes (~0.5-0.6) so motion forms first.",
}),
"ramp_end": ("FLOAT", {
"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "Denoise progress where the boost reaches peak_strength.",
}),
"num_keyframes": ("INT", {
"default": 0, "min": 0, "max": 256, "step": 1,
"tooltip": "No-LoRA path only: how many timeline image keyframes precede the references, so the node knows the keyframe/reference token split. Set this = your number of timeline image segments (e.g. 4). Leave 0 and the WHOLE guide region is treated as reference (also boosts keyframes). Ignored when an IC-LoRA populated real entries.",
}),
},
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "patch"
CATEGORY = "Koolook/LTX"
DESCRIPTION = (
"Ramp the reference's guide-attention UP at low sigma so identity locks "
"during refinement without disturbing early motion. Works WITHOUT an "
"IC-LoRA: it fabricates the per-guide attention entries for the trailing "
"reference tokens itself (set num_keyframes so it knows the split), so the "
"reference gets the same amplified-attention channel an IC-LoRA would use "
"— inside the Director, no guide-node changes. Mirror of LTX A/V Bind "
"Schedule. One pass, no re-noise."
)
def patch(self, model, num_references, peak_strength, num_keyframes=0, ramp_start=0.5, ramp_end=0.9):
m = model.clone()
if int(num_references) <= 0:
log.info("[LTXReferenceBindSchedule] num_references=0 — model passed through.")
return (m,)
try:
sigma_max = float(m.get_model_object("model_sampling").sigma_max)
except Exception as e: # pragma: no cover - live model
log.warning("[LTXReferenceBindSchedule] no model_sampling.sigma_max: %s", e)
sigma_max = 1.0
key = "diffusion_model._build_guide_self_attention_mask"
try:
underlying = m.get_model_object(key)
dm = m.get_model_object("diffusion_model")
except Exception as e: # pragma: no cover - live model
log.warning(
"[LTXReferenceBindSchedule] no %s on this model (not an LTX guide "
"model?): %s — passed through unchanged.", key, e,
)
return (m,)
diag = {}
wrapper = _make_guide_mask_wrapper(
underlying, int(num_references), int(num_keyframes), float(peak_strength), diag,
)
try:
m.add_object_patch(key, types.MethodType(wrapper, dm))
except Exception as e: # pragma: no cover - live model
log.warning("[LTXReferenceBindSchedule] guide-mask patch failed: %s", e)
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 = ref_gain(sigma, sigma_max, ramp_start, ramp_end, peak_strength)
c = params["c"]
to = dict(c.get("transformer_options", {}))
to[_REF_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(
"[LTXReferenceBindSchedule] scheduling %d reference(s): peak x%.2f, "
"ramp %.2f..%.2f of denoise.", int(num_references), float(peak_strength),
float(ramp_start), float(ramp_end),
)
return (m,)
NODE_CLASS_MAPPINGS = {"LTXReferenceBindSchedule": LTXReferenceBindSchedule}
NODE_DISPLAY_NAME_MAPPINGS = {"LTXReferenceBindSchedule": "LTX Reference Bind Schedule (Koolook)"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]