* 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>
138 lines
4.3 KiB
Python
138 lines
4.3 KiB
Python
"""Unit tests for the LTX Reference Bind Schedule ramp + entry boosting.
|
|
|
|
The pure `ref_gain` ramp and `_boosted_entries` helper are stdlib-only and
|
|
always tested; the model wiring (unet wrapper + object-patch on
|
|
_build_guide_self_attention_mask) is validated by rendering against a live LTX
|
|
2.3 model. Loaded by file path to avoid importing the package __init__.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
_MOD = Path(__file__).resolve().parents[2] / "k_ltx_reference_bind_schedule.py"
|
|
_spec = importlib.util.spec_from_file_location("k_ltx_reference_bind_schedule_under_test", _MOD)
|
|
_m = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_m)
|
|
gain = _m.ref_gain
|
|
boost = _m._boosted_entries
|
|
fab = _m.fabricate_reference_entries
|
|
|
|
SMAX = 10.0
|
|
|
|
|
|
# --- ref_gain ramp ---------------------------------------------------------
|
|
|
|
def test_early_steps_neutral():
|
|
# high sigma == start of denoise -> progress 0 -> neutral 1.0
|
|
assert gain(SMAX, SMAX, 0.5, 0.9, 2.0) == 1.0
|
|
# still before ramp_start
|
|
assert gain(SMAX * 0.6, SMAX, 0.5, 0.9, 2.0) == 1.0 # progress 0.4 < 0.5
|
|
|
|
|
|
def test_late_steps_reach_peak():
|
|
# sigma -> 0 -> progress 1 -> peak
|
|
assert gain(0.0, SMAX, 0.5, 0.9, 2.0) == 2.0
|
|
assert gain(0.05 * SMAX, SMAX, 0.5, 0.9, 2.0) == 2.0 # progress 0.95 >= 0.9
|
|
|
|
|
|
def test_midpoint_between_one_and_peak():
|
|
# progress 0.7 is the midpoint of [0.5, 0.9] -> smoothstep 0.5 -> 1.5
|
|
g = gain(SMAX * 0.3, SMAX, 0.5, 0.9, 2.0)
|
|
assert g == pytest.approx(1.5)
|
|
|
|
|
|
def test_monotonic_non_decreasing_as_sigma_falls():
|
|
prev = 0.0
|
|
for i in range(0, 21):
|
|
s = SMAX * (1.0 - i / 20.0)
|
|
g = gain(s, SMAX, 0.5, 0.9, 2.0)
|
|
assert g >= prev - 1e-9
|
|
prev = g
|
|
|
|
|
|
def test_degenerate_ramp_window():
|
|
# ramp_end <= ramp_start -> step at ramp_end
|
|
assert gain(0.0, SMAX, 0.9, 0.9, 3.0) == 3.0
|
|
assert gain(SMAX, SMAX, 0.9, 0.9, 3.0) == 1.0
|
|
|
|
|
|
# --- _boosted_entries ------------------------------------------------------
|
|
|
|
def test_boost_scales_only_trailing_refs():
|
|
entries = [{"strength": 1.0}, {"strength": 1.0}, {"strength": 1.0}] # 2 kf + 1 ref
|
|
out = boost(entries, 1, 2.0)
|
|
assert [e["strength"] for e in out] == [1.0, 1.0, 2.0]
|
|
|
|
|
|
def test_boost_multiplies_existing_strength():
|
|
entries = [{"strength": 0.8}, {"strength": 0.5}]
|
|
out = boost(entries, 1, 2.0)
|
|
assert out[0]["strength"] == 0.8
|
|
assert out[1]["strength"] == 1.0
|
|
|
|
|
|
def test_boost_noop_when_gain_one():
|
|
entries = [{"strength": 1.0}, {"strength": 1.0}]
|
|
assert boost(entries, 1, 1.0) is entries # untouched object
|
|
|
|
|
|
def test_boost_noop_when_no_refs():
|
|
entries = [{"strength": 1.0}]
|
|
assert boost(entries, 0, 2.0) is entries
|
|
|
|
|
|
def test_boost_does_not_mutate_input():
|
|
entries = [{"strength": 1.0}, {"strength": 1.0}]
|
|
_ = boost(entries, 1, 3.0)
|
|
assert entries[1]["strength"] == 1.0
|
|
|
|
|
|
def test_boost_count_capped_at_length():
|
|
entries = [{"strength": 1.0}, {"strength": 1.0}]
|
|
out = boost(entries, 9, 2.0)
|
|
assert [e["strength"] for e in out] == [2.0, 2.0]
|
|
|
|
|
|
# --- fabricate_reference_entries (no-LoRA path) ----------------------------
|
|
|
|
def test_fabricate_splits_keyframes_and_refs():
|
|
# 5 guide frames (4 kf + 1 ref), 3275 guide tokens -> ref share ~ 1/5.
|
|
out = fab(3275, 1, 4, 2.0)
|
|
assert len(out) == 2
|
|
kf, ref = out
|
|
assert kf["strength"] == 1.0
|
|
assert ref["strength"] == 2.0
|
|
assert kf["surviving_count"] + ref["surviving_count"] == 3275
|
|
assert ref["surviving_count"] == round(3275 * 1 / 5)
|
|
|
|
|
|
def test_fabricate_no_keyframes_is_all_reference():
|
|
out = fab(1000, 1, 0, 3.0)
|
|
assert len(out) == 1
|
|
assert out[0]["strength"] == 3.0
|
|
assert out[0]["surviving_count"] == 1000
|
|
|
|
|
|
def test_fabricate_entry_shape_matches_core_builder():
|
|
# core reads strength, surviving_count, and .get(pixel_mask)/.get(latent_shape)
|
|
out = fab(800, 2, 2, 2.0)
|
|
for e in out:
|
|
assert set(("strength", "surviving_count", "pixel_mask", "latent_shape")) <= set(e)
|
|
|
|
|
|
def test_fabricate_empty_when_no_refs_or_tokens():
|
|
assert fab(0, 1, 4, 2.0) == []
|
|
assert fab(3275, 0, 4, 2.0) == []
|
|
|
|
|
|
def test_fabricate_ref_tokens_clamped_at_least_one():
|
|
# tiny ref share rounds toward >=1
|
|
out = fab(10, 1, 100, 2.0)
|
|
ref = out[-1]
|
|
assert ref["surviving_count"] >= 1
|
|
assert sum(e["surviving_count"] for e in out) == 10
|