ComfyUI-Gradual-IC-LoRA
Gradual IC-LoRA time-curve nodes for LTX-2.3 (ComfyUI). Modulate IC-LoRA strength over the output clip via a user-drawn curve, applied as a per-token runtime adapter.
ComfyUI-Gradual-IC-LoRA [beta]
Beta — functionally validated, design caveat documented. All four smoke tests pass against live LTX-2.3 22B. Gradual within-clip LoRA ramps work, but read "Gradual strength: what to expect" before you build a workflow around a body-size or other spatially-drastic IC-LoRA — the model's temporal-consistency prior fights some effects more than others.
Gradually modulate the strength of a user-trained IC-LoRA (video conditioning, reference tokens at negative RoPE positions) over the output clip duration in a single continuous LTX-2.3 sampling run.
The LoRA is applied as a runtime, per-token-masked adapter — weights are never merged. Reference tokens are held at a constant strength (so the conditioning stays fully readable for the whole run); output tokens follow the curve, sampled per latent frame.
Install
Folder-drop into ComfyUI/custom_nodes/ and restart ComfyUI. No extra Python
deps beyond what ComfyUI already ships (torch, numpy, Pillow).
ComfyUI/custom_nodes/ComfyUI-Gradual-IC-LoRA/
Nodes
IC Strength Curve
Custom canvas widget. X = normalised clip time 0..1, Y = strength multiplier
(default range -0.5 .. +2.0, adjustable via the y_min/y_max widgets).
- Click empty space to add a keyframe; drag to move; right-click or double-click a keyframe to delete (minimum 2 kept).
- Interpolation: click the curve: chip to cycle
monotone → catmull → linear. - Presets:
constant,linear,ease in,plateau step. - Output:
IC_CURVE(serialized keyframes + mode). Resampling to frames happens in IC-LoRA Curve Apply, so the curve node never needs the frame count. - State serializes into the workflow JSON via a hidden backing widget. For
headless / API use, set
preset_fallbackinstead of drawing.
IC-LoRA Curve Apply
Patches an LTX-2.3 MODEL with the IC-LoRA as a masked runtime adapter.
Inputs
| input | notes |
|---|---|
| model | LTX-2.3 MODEL, after all static LoRAs |
| video_latent | the final video-only latent; used only as a timing reference; passed through |
| lora_name | IC-LoRA safetensors (video-branch keys) |
| curve | IC_CURVE |
| strength_global | multiplies the curve (output tokens only) |
| reference_strength | constant mask value for reference tokens (default 1.0) |
| ref_token_count | reference token count; set this unless ic_layout is wired |
| ic_layout (opt) | layout metadata from your IC-LoRA inference node, if a handle exists; overrides ref_token_count |
| num_latent_frames (opt) | 0 = auto from video_latent temporal dim |
| rendered_frames (opt) | 0 = ignore; else cross-checks via the LTX 8× rule |
| ref_tokens_first (opt) | are reference tokens the leading block of the sequence? default true |
| token_order (opt) | frame_major (default) or spatial_major token flatten order |
| low_vram (opt) | compute B(A·x) in sequence chunks |
Outputs: model (patched), video_latent (passthrough, for stacking),
schedule_info (STRING: resolved frame count, per-frame strengths, ref token
count, patched-module count).
Mechanism
out = base(x) + s ⊙ (α/r)·B(A·x)
s is a per-token scalar mask broadcast over the hidden dim:
- Reference tokens →
reference_strength(constant). - Output tokens →
curvesampled at the token's latent-frame index, timesstrength_global.
Latent frames follow LTX 8× temporal compression / 8n+1:
latent_frames = (rendered_frames − 1) / 8 + 1.
The mask rule is uniform across all patched projections (Q, K, V, out, FFN) — these are per-token Linears, so one rule covers everything. No Q-only casing.
Token-layout hard fail. At each forward we check
seq_len == ref_token_count + output_tokens and that
output_tokens divides evenly by num_latent_frames. On mismatch the node
raises with a detailed message rather than silently mis-aligning the curve
to the wrong frames (the worst-case failure mode). The mask is built once per
unique sequence length and cached.
Video modules only. Patch targets are derived from the LoRA's own keys
via ComfyUI's comfy.lora key mapping, so only the modules the (video-branch)
IC-LoRA touches get patched. Any audio-branch keys are skipped with a warning.
IC Curve Preview (optional)
Renders the resolved per-frame strength as an IMAGE (pure-PIL) with frame
indices and second markers at a given fps. Useful QA before a long render.
Wiring
[IC Strength Curve] ──curve──┐
▼
[LTX MODEL (post static LoRAs)] ──model──► [IC-LoRA Curve Apply] ──model──► [Sampler]
[final video-only LATENT] ──video_latent──► └─video_latent─► (passthrough)
- Place IC-LoRA Curve Apply last in the MODEL chain, after all static LoRAs, immediately before the sampler.
- Feed it the final video-only latent.
- Set
ref_token_count(or wireic_layout) to your IC reference-token count.
Stacking two Curve Apply nodes (different LoRAs) composes additively — each
wraps the previous adapter — and video_latent passes straight through.
Smoke test results (all PASS, RTX 5090, LTX-2.3 22B fp8)
- Gate — const 1.0 matches normal IC-LoRA ✓ (9.6% latent rms, fp8 floor;
frames visually identical). Sampler switched to
eulerfor a noise-free gate (euler_ancestralinjects unseeded per-step noise; two identical runs differ ~70% in latent space). - Reference bleed (const 0.0 / ref 1.0) ✓ — output vs base = 8.3% (fp8 floor). No meaningful reference-token bleed → phase-2 attention bias not needed.
- Linear ramp 0→1 (the feature) ✓ — per output latent frame, early frames track base and late frames track full with a clean monotonic crossover → per-token time-curve masking + frame ordering correct.
- Stacking ✓ — two chained Curve Apply nodes at 0.5 each vs a single at 1.0 = 9.8% (fp8 floor) → composes additively.
Three application bugs and one cross-run leak were found and fixed during smoke
testing (see commit history). Driver at smoketest/run_gate.py.
Verified layout for the test workflow
seq_len 15840 = 18 latent frames × 880 tokens; output = 9 latent frames
(num_latent_frames=9), reference = 7920 tokens (ref_token_count=7920),
token_order=frame_major, ref_tokens_first=True. Spatial token count is
constant at 880 for the 1280‑long‑side resize this workflow uses (invariant
across aspect ratios).
Gradual strength: what to expect
A within-clip IC-LoRA ramp is not the same as rendering two separate clips at different strengths and cross‑fading. LTX enforces a strong temporal‑consistency prior: the model commits to roughly one coherent body, style, or scene state per continuous shot. How much this matters depends on your LoRA:
| LoRA type | Example | Temporal‑consistency impact | |---|---|---| | Drastic spatial change | body‑size, object insertion/removal | Strong. The start of a ramp reads diluted vs a constant render at the same strength, because the weaker tail pulls the whole clip toward the average. You may need to push strength well above your usual constant‑render value or sacrifice gradualness for impact. | | Style / colour / lighting | film‑grade, painterly, desaturation | Mild to none. These have no hard consistency prior — the model readily blends strength levels, and a smooth ramp tracks faithfully. | | Fine‑detail / texture | skin detail, fabric, sharpening | Mild. Generally tracks well; check for temporal flicker at very low strengths. |
Mitigation for spatially‑drastic LoRAs (body‑size, etc.): an induced
camera motion trick — apply a subtle synthetic pan/zoom to the input, render
with the ramp, then reverse the pan on the decoded output — can approximately
double the visible gradient span by relaxing the static‑body prior. A
production‑ready helper ships in smoketest/fake_motion.py. See
smoketest/FINDINGS.md for the full quantitative matrix.
If you need both a fully‑strong start AND a fully‑weak end for a spatially‑drastic LoRA, the reliable option is two separate constant‑strength renders cross‑faded in edit — that sidesteps the model's consistency prior entirely.
Limitations
torch.compileunsupported (dynamic per-forward mask + object-patch wrappers). SageAttention keeps working — we only wrap projection Linears, not the attention compute.- Video-only. Audio-branch keys are skipped.
- fp8 accuracy floor. On fp8‑quantised LTX models the baseline merges the LoRA into fp8 weights and requantizes; our adapter applies it separately at higher precision. A constant‑1.0 curve differs from the standard loader by ~9–10% latent rms — visually imperceptible, but it means the gate can't be bit‑exact on fp8 hardware. On fp16/bf16 models we expect exact matching.
- Temporal‑consistency dilution for spatially‑drastic LoRAs (see "Gradual strength: what to expect").
- Composes with other
ModelPatcher-based patches viamodel.clone()+add_object_patch. Upstream weight‑patches (e.g. a distilled LoRA) are re‑keyed onto the wrapped.basemodules automatically.
Assumptions (verified against live LTX-2.3)
These were confirmed against live LTX-2.3 during smoke testing:
- Token flatten order is
frame_major(all spatial tokens of frame 0, then frame 1, …). ✓ - Reference tokens are the leading block (
ref_tokens_first = true). ✓ comfy.lora.model_lora_keys_unetrecognises PEFT‑style.lora_A/.lora_Bkeys for LTX-2.3. ✓comfy.lora.load_lorareturnscomfy.weight_adapter.LoRAAdapterobjects (not legacy("lora", payload)tuples) on this ComfyUI version. ✓
Tests
python3 tests/test_curve.py — pure-Python checks of the curve math, including
the smoke-test-1 invariant at the curve level (constant 1.0 → all-ones for any
frame count). No torch/numpy required.