Skip to content

Add LTX-2.5 DFR pipeline (keyframe slots, spatial detailing, tiled temporal rounds) - #14567

Open
alexanderar wants to merge 1 commit into
huggingface:mainfrom
alexanderar:ltx-dfr-on-upstream
Open

Add LTX-2.5 DFR pipeline (keyframe slots, spatial detailing, tiled temporal rounds)#14567
alexanderar wants to merge 1 commit into
huggingface:mainfrom
alexanderar:ltx-dfr-on-upstream

Conversation

@alexanderar

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds LTX2DFRPipeline — Diffusion Fidelity Rendering for LTX-2.5 — ported from the Lightricks
reference implementation.

Stage 1 generates video plus extra single-pixel-frame keyframe slots at a fraction of the requested
resolution, on a segment grid aligned to the VAE's temporal border. Slots relax the effective temporal
compression at those positions, so the surrounding video can be conditioned on genuinely new frames rather
than interpolated ones. The half-resolution result is kept as an IC-LoRA reference while both the video and
the slot keyframes are upsampled in latent space; stage 2 re-denoises at twice that resolution with the
slots re-attached and an optional x2 spatial detailing IC-LoRA active for that stage only.

Two optional knobs extend it:

  • temporal_upscalings (0–2) — each round doubles the frame rate: the canvas is temporally upsampled,
    split into 2 ** round tiles that meet at shared keyframes, given fresh mid-segment slots, and densified
    with ancestral Euler. Each tile cross-attends to the slice of the frozen stage-1 audio covering its own
    playback window, so both sides of a seam densify against the same sound. Requires the optional
    temporal_latent_upsampler component.
  • spatial_upscalings (1 or 2)2 starts the base canvas one more factor of two down and adds a
    full-resolution detailing pass after the temporal rounds. That pass does not fit in one sequence, so it
    denoises the whole canvas in a single loop and tiles the transformer call inside it, which means every
    Euler step steps a canvas whose tiles have already agreed on their overlaps. Spatial tiles blend under a
    trapezoidal mask; temporal tiles are cut on the keyframe seams the last refine round stitched on.

Whatever padding the canvas needs internally, the caller always gets
(num_frames - 1) * 2 ** temporal_upscalings + 1 frames back.

Notable details

  • Transformer: keyframes_abs_pos_embedding was already stored for load/save but never consumed. This
    wires it into the forward through a new optional video_keyframes_mask argument, which only a
    keyframes-aware pipeline passes — other pipelines are unaffected, and the default config leaves the branch
    inert.
  • Conditioning fps is snapped to 60 above 30 at every stage rather than merely capped. RoPE time is
    pixel_frame / fps, and the transformer is trained around 24/25/30 and 60; a temporal round taking 24 fps
    to 48 lands between those and shows as stutter at the latent borders. Playback fps is unchanged.
  • Resolution: height / width must be divisible by 2 ** spatial_upscalings times the VAE's spatial
    compression ratio — 64 at the default, 128 at spatial_upscalings=2. So a 4K run is 3840x2176, not
    3840x2160. That rule is checked ahead of the generic one so the error names the divisor a DFR caller
    actually has to satisfy.
  • Sampler: the temporal rounds need an ancestral (SDE) Euler step with eta=0.5, which is not
    expressible through FlowMatchEulerDiscreteScheduler — its stochastic_sampling branch renoises fully
    from x0 with no eta, no intermediate sigma_down, and no variance-preserving rescale, so it differs
    even at eta=1. ancestral_euler_step is a module-level function with that reasoning in its docstring.
  • New files: pipeline_ltx2_dfr.py (pipeline) and dfr_layout.py (canvas layout: segment grid, tile
    plans, blend masks, token plan). The conversion script gains --temporal_latent_upsampler for the x2
    temporal latent upsampler, which is not part of the base repo.

Tests

Pipeline-level (tests/pipelines/ltx2/test_pipeline_ltx2_dfr.py) and layout-level
(test_ltx2_dfr_layout.py), plus two additions to the existing transformer model tests for the keyframe
embedding. Following .ai/references/testing.md: pytest-style config class + PipelineTesterMixin /
MemoryTesterMixin only, real components at tiny config, no LoRA or @slow tests in this first pass.

  • 182 passed, 13 skipped (pipeline + layout + transformer model tests), on the current main base
  • make quality (including utils/check_ai.py), utils/check_copies.py, utils/check_dummies.py clean

Upstream note

Porting this surfaced a bug in the reference implementation: an image conditioning with a non-zero frame
index is mis-placed (and usually dropped) after a temporal round, because its position is never scaled onto
the round's longer canvas. Reported to the Lightricks team, confirmed, and being fixed upstream. This
pipeline scales in both the refine rounds and the epilogue, and the placement is pinned by a test.

Before submitting

  • Did you use an AI agent (Claude Code, Codex, Cursor, etc.) to help with this PR? If so:
    • Did you read the Coding with AI agents guide?
    • Did you run the self-review skill on the diff?
    • Did you share the final self-review notes in the PR description or a comment?
  • Did you read the contributor guideline?
  • Did you read our philosophy doc? (important for complex PRs)
  • Was this discussed/approved via a GitHub issue or the forum? Please add a link to it if that's the case.
  • Did you make sure to update the documentation with your changes? Here are the
    documentation guidelines, and
    here are tips on formatting docstrings.
  • Did you write any new necessary tests?
  • Are you the author (or part of the team) of the model/pipeline (only applicable for model/pipeline related PRs)?

Self-review notes

Ran the self-review skill over four rounds; final verdict READY, no blocking issues. Nine findings were
raised and resolved across rounds 1–4:

Finding Resolution
Dead video-only denoise path (video_only was always False) removed; audio is present in every pass, matching the reference
Dead negative-time branch in the tile plan removed; every coordinate source clamps the temporal start to >= 0
Three helpers on the pipeline class that never touched self moved to dfr_layout / module level
Tests asserting on captured arguments now assert on returned state, or call the pure layout functions directly
_audio_latents_for_tile returned a count nothing used returns the window only
owned_segment_counts had a single caller inlined; behaviour asserted through split_canvas_at_seams
Five layout tests living in the pipeline test file moved to test_ltx2_dfr_layout.py
Loose return types in dfr_layout typed as LTX2DFREpilogueTile / LTX2DFRTokenPlan, matching the module's existing NamedTuple idiom
per_token_sigma read as scheduler duplication comment added explaining why the scheduler's per_token_timesteps path is not equivalent

Findings I deliberately did not fix

  • choose_segment_length has a single caller, which the coding-style guide suggests inlining. Kept because it
    mirrors a function of the same name in the reference implementation, which keeps future port diffs
    readable. The contrast is owned_segment_counts, which was inlined precisely because it had no
    reference counterpart.
  • video_tile_plan lives in dfr_layout but its tests live in the pipeline test file. They need real
    RoPE coordinates from prepare_latents; moving them would drag a pipeline fixture into a pure-layout test
    file. Placement follows the dependency.
  • Three tests still wrap prepare_latents / denoise to reach internal state. They assert on what
    those methods returned (real RoPE coords compared against transformer.rope.prepare_video_coords, real
    tensors across two real passes), not on the arguments passed in. There is no cheaper behavioural proxy for
    cross-pass data flow at dummy resolution.
  • ancestral_euler_step is a hand-rolled sampler step. Verified against the scheduler rather than
    assumed — see "Sampler" above. Flagging it so a reviewer sees the verification instead of re-deriving it.

Deliberate departures from the reference

  • LTX2VideoCondition.index is a latent index, per diffusers convention (the reference uses a pixel
    index). Positions are carried in pixel space internally so a scaled position is never floored onto the
    latent grid.
  • The shipped detailing LoRA is calibrated for strength 0.5, and the reference hardcodes that. This
    pipeline documents it instead of enforcing it: set_adapters writes a scale for every adapter it
    names (None resolves to 1.0) and there is no API to read a weight back, so enforcing it would silently
    clobber the caller's other adapters. Happy to change this if maintainers prefer — the alternative is
    reaching into peft's BaseTunerLayer.set_scale for the single adapter.

Not ported

The reference recently made its DFR decode keyframe-aware (passing the encoded keyframe planes to the
DiffVAE decoder). That is blocked here: neither AutoencoderKLLTX2Video nor ltx2_diffusion_decoder.py has
a keyframe path, so it needs the reference's dual-stream / joint-neighbourhood-attention decoder ported into
the diffusers VAE first. That is models-level work, better as its own PR. Everything else from the
reference's current main is included.

Fidelity checks

Since this is a port, correctness was checked against the reference rather than only by unit test:

  • dfr_layout is bit-exact against the reference's tiling and layout modules over ~1200 cases (segment
    grid, seam splits, count splits, both mask shapes, full temporal tile plans).
  • Condition placement is identical to the reference's fix across 54 combinations of VAE ratio, round
    count and tile offset — including the keep/drop decisions.
  • Constants match one-for-one: anchor strength 0.95, ancestral eta 0.5, fps snap 60/30, epilogue
    spatial overlap 12, epilogue keyframe strength 1.0.
  • Audio configuration matches per pass: stages 1 and 2 unfrozen (stage 2 re-noised at stage_2_sigmas[0]),
    temporal tiles and the epilogue frozen at sigma 0.

Two gotchas worth writing down (proposal, not in this diff)

Both came out of this port and apply to pipelines generally, so they are not included here — this PR stays
scoped to the pipeline. Happy to send either as its own small PR against .ai/references/pipelines.md if
you think they are worth recording:

  1. set_adapters writes a scale for every adapter it names, and weights=None resolves to 1.0, so
    using it to pin one adapter's strength silently resets every other active adapter — and there is no API to
    read a weight back and preserve it. set_adapter (singular) activates without touching scaling.
  2. An appended conditioning token is positioned by pixel, not by latent index. Routing an arbitrary pixel
    position through a latent-index API floors it onto the latent grid, which bites whenever the position is
    not a multiple of the temporal scale.

Who can review?

@yiyixuxu @dg845

Ports DFRPipeline from the Lightricks reference. Stage 1 generates video plus
single-pixel-frame keyframe slots at a fraction of the requested resolution on
a VAE-aligned segment grid; both are spatially latent-upsampled and stage 2
re-denoises at twice that resolution with the slots re-attached and an optional
spatial detailing IC-LoRA active for that stage only. Optional temporal x2/x4
refine rounds tile the canvas at shared keyframes and densify with ancestral
Euler. With spatial_upscalings=2 a full-resolution detailing epilogue follows
the rounds.

The transformer already stored keyframes_abs_pos_embedding for load/save; this
wires it into the forward through a new video_keyframes_mask argument, which
only a keyframes-aware pipeline passes, so other pipelines are unaffected.

The epilogue denoises the whole canvas in one loop and tiles the transformer
call inside it, so every Euler step steps a canvas whose tiles have already
agreed on their overlaps. Spatial tiles blend under a trapezoidal mask, since
neither side of a height or width border holds a known answer. Temporal tiles
are cut on the keyframe seams the last refine round stitched on: both windows
reproduce a shared keyframe there, so the later one drops its run-up under a
rectangular mask rather than averaging it. Conditionings are attached once on
the whole canvas and filtered per tile at the token level, and a keyframe two
windows share is one token they both read.

The epilogue is handed its keyframes rather than asked to generate them. Each
carry plane is decoded on its own -- the VAE is causal, so a stacked decode
would bleed neighbours -- then Lanczos-stretched x2 in RGB and encoded again at
the output resolution, and pinned fully clean. Only the video latent is
spatially upsampled.

Conditioning fps is snapped to 60 above 30 rather than merely capped there, at
every stage. RoPE time is pixel_frame / fps, and the transformer is trained
around 24/25/30 and 60; a temporal round taking 24 fps to 48 lands between
those, and it shows as stutter at the latent borders. Playback fps is
unchanged, so 24 fps with one round still ships 48 fps.

A condition's index is read on the canvas num_frames asks for, and the moment
it names is carried onto each refine round's longer canvas by scaling its pixel
position by 2**round. The scaled position does not generally land on a latent
boundary, so it travels as a pixel index rather than through the public latent
index; a keyframe conditioning is appended as extra tokens instead of being
spliced into the base grid, so it does not need to.

height and width must be divisible by 2**spatial_upscalings times the VAE's
spatial compression ratio, which makes 4K 3840x2176 rather than 3840x2160. That
rule is checked ahead of the looser one every LTX-2 pipeline applies, so the
error names the divisor a DFR caller actually has to satisfy.

Four details are easy to get wrong, and each is covered by a test after showing
up as a visible seam at a tile handover:

- The ancestral step injects noise into every token, so the conditioning blend
  has to be re-applied afterwards. Skipping it lets the strength-0.95 anchor
  keyframes erode over the schedule, and those anchors are the only thing
  pinning adjacent tiles onto the same content.
- Velocity is converted to x0 with each token's own noise level, not the scalar
  schedule sigma: a token held at strength s sits at (1 - s) * sigma.
- Each tile draws its ancestral noise from a generator seeded
  seed + 1000 * round + tile, kept separate from the main generator so the
  draws do not consume state the next tile's initial noising reads.
- Two tiles invent the slot that falls in the later one's dropped lead-in. The
  stitch keeps the earlier tile's frames there, so the earlier tile's copy is
  the one the canvas holds, and the one the next round must anchor on.
@github-actions github-actions Bot added documentation Improvements or additions to documentation models tests utils pipelines size/L PR with diff > 200 LOC labels Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation models pipelines size/L PR with diff > 200 LOC tests utils

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant