Commit Graph
140 Commits
Author SHA1 Message Date
alisson-anjosandClaude Opus 5.5 bf473e1dbd Shot Planner / Shot Loop: split long videos into model-sized shots (1.48.0)
A video model only follows a guide inside the clip length it was trained on, so long
sources are split into shots that fit and run once per shot.

- BFS Shot Planner: interactive Vue timeline (thumbnails, cut-detector curve, draggable
  boundaries, split/merge, per-shot reference gallery with upload, per-shot prompt).
  Splits at camera cuts via PySceneDetect (adaptive/content, built-in fallback), fixed
  length, or by hand; lengths snap to the model grid (H3 17n+5, LTX/Wan 8n+1, 4n+1).
  Outputs the shots as a ComfyUI list so everything downstream runs once per shot.
- BFS Shot Unpack: guide frames, references, prompt, length, audio of one shot.
- BFS Shot H3 Conditioning: native MiniMax H3 conditioning per shot (aligned guide,
  native reference video, or both).
- BFS Shot Join: concatenates in order, trims each shot to its true length, cross-fades
  soft joins with the real overlap frames, returns the soundtrack.
- Queue loop mode: one shot per run stored on disk, auto re-queue, nodes after the join
  blocked until the last shot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:32:32 +00:00
Codex f145527ea5 Add H3 reference RoPE options and caption-only identity conditioning 2026-09-30 20:30:01 +00:00
Codex bd23236bdd Add aligned downscaled MiniMax H3 guides and image/video targets 2026-09-30 17:26:56 +00:00
alisson-anjosandClaude Opus 5 5c8c41abbd LoRA Surgery: carry a recipe to the save node without copying JSON (1.45.0)
Getting a recipe from the interactive node into the save node meant copying the rules JSON
between two text widgets, which is a bad way to move state and easy to get wrong.

Two paths now. BFS LoRA Surgery gained a `rules` output that can be wired straight into the
save node's `rules` input, so the file is produced as part of the run. And the panel has a
"save as file" button with an optional name, which posts to a new /bfs/lora/save route and
reports the result inline, for when you are still exploring and do not want a second node in
the graph.

Both go through one write_surgery function, so the two paths cannot drift apart. That
function is the old node body lifted out, not a copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 12:33:29 +00:00
alisson-anjosandClaude Opus 5 e72dd2569a LoRA Surgery: detect the block axis from the file, and list every layer (1.44.0)
The parser took the last numeric component of a key as the block index. That is wrong
whenever a name carries more than one number: transformer_blocks.12.attn1.to_q would be read
as block 1 of a family ending in attn, and a nested layout such as
down_blocks.0.attentions.1.transformer_blocks.0.ff.net.0.proj collapsed into a family called
ff.net. The block axis is now chosen per path template as the numeric slot that varies most
across the whole LoRA, which is information a single key does not have. Numbers outside that
slot stay in the module name, so attn1 and attn2 stay distinct rather than folding together.

The grouped tree is a summary, and a summary can hide things, so the panel now also has an
"every layer" view: the full module list with a filter box, each row showing its norm and
rank, and a + that writes a rule matching exactly that path. The structure endpoint returns
that list alongside the grouped one.

Checked against the body-swap recipe: same 136 of 192 modules kept, 56 dropped, 96 scaled as
before the change, and attn.to_out.0 is still treated as attention rather than as block 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 12:30:03 +00:00
alisson-anjosandClaude Opus 5 5073cf00ae LoRA Surgery: keep the panel's configuration across reloads, and stop implying Qwen names (1.43.2)
Two fixes.

The panel lost its configuration when a workflow was reopened. The Vue app mounts in
nodeCreated, which runs before ComfyUI restores widget values in configure, so it read an
empty widget and the first edit would then overwrite the stored recipe. The node now wraps
onConfigure and tells the panel to re-read once values have landed, and the rules widget is
explicitly marked serializable with a serializeValue, so the configuration travels with a
shared workflow rather than living only in the session.

The widget now stores {rules, ui} instead of a bare array, so the selected family, type,
block range and scale come back too, not just the rules. A bare array is still accepted, for
workflows saved by the previous version and because the Python side takes either shape;
verified both produce identical results.

The inline guide named gate_up as if it were universal. It now says up front that names
depend on the base model, gives the three architectures verified so far with their actual
family and module names and block counts, and frames the finding as the MLP input projection
whatever the model calls it, explicitly a starting point rather than a rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 12:24:49 +00:00
alisson-anjosandClaude Opus 5 ee4d2eecb8 LoRA Surgery save: bounded filenames and metadata that explains the file (1.43.1)
Filenames could exceed the OS limit: the slug was capped at 110 characters but the final
name is stem + slug, so a long source name pushed it past NAME_MAX (255 bytes on Linux, and
Windows caps the whole path near 260 without long paths). Names are now fitted to 160 bytes,
trimming both halves and appending a short hash of the full recipe when needed, so two
different recipes still never produce the same name. A path over 240 characters logs a
warning, since the folder above us is not ours to control.

Metadata now records what was applied rather than just that something was. The original
metadata is preserved untouched, and next to it:

  bfs_surgery_summary  one readable line, for viewers that know nothing about this tool
  bfs_surgery          tool, UTC timestamp, source filename and its sha256, the readable
                       recipe, the exact rules, and module counts (total/kept/dropped/scaled)
  ss_output_name       updated to the new filename

The sha256 matters more than the filename: it ties the result to the checkpoint it came from
even after either one is renamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 12:15:30 +00:00
alisson-anjosandClaude Opus 5 ce11d082fd LoRA Surgery: a save node that names the file after the recipe (1.43.0)
BFS LoRA Surgery (save) applies the same rules and writes a .safetensors into models/loras,
for when a recipe is settled and you want it usable outside the workflow that produced it.

With no filename given, the name is derived from the rules themselves:
mylora__attn-x1.15__gate_up-b8_15-off__all-b16_23-off.safetensors. Names get sanitized, so a
type like *mlp* or a regex match still yields a valid file. The recipe also goes into the
safetensors metadata under bfs_surgery, next to the original metadata, so a shared file can
explain itself later.

Verified against the study output: saving the body-swap recipe produces the same 136 of 192
modules, and the effective dW matches the file produced by the standalone script to 3e-3
relative. The factors differ because the node scales only the up/B side while the script
split the scale across both, but the product is what the model sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 12:09:13 +00:00
alisson-anjosandClaude Opus 5 309daf6b38 LoRA Surgery: drag the block ruler instead of shift-clicking (1.42.3)
Shift-click was the wrong gesture inside a node: on the ComfyUI canvas it starts a text
selection, so picking a range fought the editor. Ranges are now painted by dragging across
the ruler, with pointerdown calling preventDefault and stopPropagation so the drag does not
reach the canvas and move the node.

Click-to-toggle still works, since a click is a one-cell drag, and the gesture adds or
removes depending on the cell you start from. Also added a text field that both shows and
accepts the selection (8-15, 0,4,7, 8-15,24-31) and shortcuts for all / first half / last
half / invert / none, which cover most of what the ruler is used for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 12:00:49 +00:00
alisson-anjosandClaude Opus 5 e79d590fd5 LoRA Surgery: controls on top, reference below, and an inline guide (1.42.2)
The panel opened with the layer tree, which pushed the controls off screen on a LoRA with
32 blocks: you had to scroll past the reference material to do anything. Order is now
header, guide, Selection, Rules, Layers.

The guide is a collapsed block that explains what the node is for, how to read the ||dW||
bars, where to look first (the MLP input projection), that later rules override earlier
ones, and the two things worth knowing before trusting a result: a variant can win every
metric and still have destroyed what you trained, and pruning after training is not the
same as training with those layers excluded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 11:59:56 +00:00
alisson-anjosandClaude Opus 5 be0bf3e7ca Drop em dashes from the node's prose (1.42.1)
Plain punctuation reads better and does not carry the stylistic tell. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 11:49:10 +00:00
alisson-anjosandClaude Opus 5 816d942fb8 Release 1.42.0 — LoRA Surgery
Bumps the version so the registry workflow publishes, and keeps the vendored Vue out of
ComfyUI's extension scan: server.py globs **/*.js recursively under WEB_DIRECTORY and
imports every hit as an extension, which would have pulled in the Vue bundle as if it
were one. Renaming it to .mjs sidesteps that — mimetypes still resolves it to
text/javascript, so the module import is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 11:37:26 +00:00
alisson-anjosandClaude Opus 5 e9a27c765c BFS LoRA Surgery: read any LoRA's layers and blocks, scale or drop them live
A defect a LoRA learned — smoothed skin, identity drifting at distance, a pose it
refuses to copy — usually lives in one module family and a range of blocks, not in the
whole adapter. This node makes that testable without retraining: pick a family, pick
blocks, set a scale, queue. Nothing is written to disk, so iterating is a slider away.

Model-agnostic by construction. The structure is discovered from key names rather than
hardcoded, covering the diffusers (lora_A/lora_B) and kohya (lora_down/lora_up + alpha)
conventions. Verified against three architectures with different naming: Qwen-Image-2.1
(transformer_blocks, 32), Krea 2 (blocks 28 + txtfusion) and MiniMax-H3 (blocks 50 +
token_refiner).

Scaling multiplies only the up/B factor, since (sB)A = s(BA) — exact under either alpha
convention, and negative scales stay usable.

The panel is a Vue app in a DOM widget, with Vue vendored so it works offline. It lists
each family's module types sorted by ||dW||, which reads as a map of where training
invested. Selections convert to a regex on demand for cases the tree cannot express.
Rules are ordered and later ones win, so the UI says so and lets you reorder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 11:26:37 +00:00
alisson-anjos 12bd7a73b1 Shape the crop to the latent's aspect, not the latent to the crop (1.41.1)
1.41.0 fixed the wrong half of this. The deformation came from the crop and the
latent having different ASPECTS -- a mask around a face gives a portrait box,
sampled in a landscape latent it is stretched on the way in and squeezed back on
the way out -- and rebuilding the latent at the crop's size did remove the
mismatch, but by throwing away the resolution that cropping exists to buy. A
face 200 px tall in the source was then sampled at 200 px, which is the problem
the crop was there to solve.

The planner already takes an aspect_ratio and we were passing 0.0, letting the
box follow the subject. It now gets the connected latent's aspect, and
_static_box grows its short side to match when the planner is not in play. The
box still contains the subject and stays divisible by the grid.

So the latent's size is yours again -- it is the sampling resolution, choose it
for the quality you want -- and its shape no longer has to be guessed.

68 tests; the new ones pin that a landscape latent gives a landscape box, that
the box still contains the subject, and that a free aspect still follows it.
2026-09-03 17:08:17 +00:00
alisson-anjos dab4233e48 Rebuild the latent to the crop instead of asking for it (1.41.0)
The crop's size is derived from the mask, so an EmptyLTXVLatentVideo built
upstream cannot know it -- and a latent that disagreed was not an error
anywhere. The model sampled at the latent's shape and the paste-back squeezed
the result into the box: a face crop is portrait, sampled into a landscape
latent it came back visibly deformed, and under crop_mode zoomed the deformation
changed frame to frame. The only cure was to run once, read the size off the
debug string, rebuild the latent and run again, after every change to the mask.

The sampler now rebuilds the latent's video stream at the crop's shape when they
disagree, keeps the audio stream as it is, and reports it in debug. Nothing is
lost: at denoise 1.0 the initial latent contributes nothing, and the inpainting
path overwrites the video stream with the encoded guide regardless.

BFS Crop Size stays useful for building the latent at the right size up front
(skipping the rebuild) and for taking the per-frame boxes straight to BFS Paste
Back, but it is no longer the price of a correct result.

68 tests.
2026-09-03 16:52:09 +00:00
alisson-anjos c145b091d7 BFS Crop Size: the crop's size before anything is sampled (1.40.0)
The sampler derives its box from the mask, so the size to build the empty latent
at was only knowable after a run: sample a whole clip, read one number off the
debug string, rebuild the latent, sample again -- and repeat after every change
to the mask or the crop widgets.

Getting it wrong does not look like a sizing problem. A mask around a face in a
1920x1080 frame gives a portrait box, 512x672 say; sampled into a 768x512
landscape latent the crop is stretched horizontally, and the paste-back then
squeezes the result back into the portrait box. The face comes out deformed, and
under crop_mode: zoomed the deformation changes frame to frame.

The new node runs the same planner on the same mask and returns width, height,
the per-frame boxes and a note, so the latent can be built up front. The planner
is deterministic, so identical crop widgets give the sampler the identical box.

Also factored the per-frame box normalisation the sampler did inline into
_boxes_per_frame, which both nodes now share.

63 tests.
2026-09-03 16:50:39 +00:00
alisson-anjos b5d08490b0 Drop the overlap when concatenating chunks (1.39.2)
Chunked sampling built its chunks with an overlap for continuity and then
concatenated them whole, so the overlap was emitted twice. 97 frames at tile 72
overlap 16 gave 15 latent frames where the clip has 13: the output ran longer
than the input, and from the first seam on, frame i was no longer moment i.

Pasting back made it obvious rather than subtle -- crop_bboxes has one box per
source frame, so every frame after the seam went into a box belonging to a
different moment, and with crop_mode: zoomed into a box of a different size too.
That is the "crooked and out of sync" this was reported as.

Each chunk after the first now contributes only its tail, the part the previous
chunk did not already cover, so the concatenation is exactly the clip's latent
length. Single-chunk runs are untouched.
2026-09-03 15:45:58 +00:00
alisson-anjos 221d9382a0 Stop import comfy.nested_tensor from shadowing the module (1.39.1)
`import comfy.nested_tensor` inside execute() binds `comfy` as a LOCAL for the
entire function, so every other comfy.* use in it raises UnboundLocalError -- at
compile time Python decides the name is local, and the AV branch that would have
bound it does not run on a video-only latent.

It shipped in 1.37.0 with the chunk-offload line:

    got = got.to(comfy.model_management.intermediate_device())

which only runs when a clip is long enough to be sampled in more than one chunk.
That is why it survived the tests and the short runs and failed on the first
real long video:

    UnboundLocalError: cannot access local variable 'comfy'

Imported as `from comfy import nested_tensor as _nested` now, which binds
`_nested` and leaves the module-level `comfy` visible. A test asserts neither
node's execute() has `comfy` in co_varnames, which catches the whole class of it.
2026-09-03 14:44:49 +00:00
alisson-anjos c79629dc80 Confine the paste to the mask even with no crop (1.39.0)
_paste_back gave up when there was no crop box and returned the sampler's frame
whole. Nothing outside the mask *changed*, but the entire frame had been through
the VAE's encode/decode, so every pixel the edit never touched came back softer.
For a pointwise edit, or anything sharpness-adjacent, that is the opposite of
what the mask was for.

With no crop and a mask, the full frame is now composited into the ORIGINAL
frames through that mask, the way the crop path already did: outside the mask
the output is the source verbatim, never a round trip of it. The mask's own blur
supplies the soft edge -- there is no box border to feather here.

This makes crop_mode a resolution decision again. Before it, preserving the
untouched pixels required turning the crop on even when the region already had
all the resolution it needed.

52 tests; the new ones pin that outside is the source byte for byte, inside is
the result, no mask still composites nothing, and a result of another size is
brought back to the frame first.
2026-09-03 14:27:51 +00:00
alisson-anjos df32bdc0fe BFS Sampler: optional identity, and put the new widgets back at the end (1.38.0)
Three things.

`identity_image` moves from required to optional. The node was never head-swap
specific below the surface -- the reference slot is simply not packed when it is
empty, and everything else (crop, mask, inpainting, chunking, paste-back) is
indifferent to which LoRA is running. An instruction-edit, restyle or sharpening
IC-LoRA now runs on the guide alone. IMAGE is a link, not a widget, so this
costs no widget position.

Renamed to "BFS Sampler (crop · mask · loop)" and "BFS Paste Back". The
registration keys are deliberately unchanged: saved workflows reference those,
and only the display name is free to move.

And the fix for the reason this was noticed: 1.37.0 inserted auto_config and
identity_headroom BEFORE crop_mode. ComfyUI stores widgets_values positionally,
so every later value in every saved workflow shifted two slots -- crop_mode's
string landed in auto_config, crop_scale's float in identity_headroom, and the
graph failed validation with "input out of range" on crop_scale and
crop_divisible_by. Both widgets are appended at the end now, with a comment
saying why, and a test pins the historical order so the next addition cannot
repeat it. ltx_multiple_controls.py already carried that rule in a comment;
this file did not follow it.

48 tests.
2026-09-03 14:07:39 +00:00
alisson-anjos a2b499ce3e Stop the auto config from blowing the mask hole open (1.37.1)
Two compounding mistakes in 1.37.0's derivation, reported as a hole much larger
than the head with the new head generated floating inside it:

- the upward slack was scaled by the mask's HEIGHT (hh * delta / tilt). Height
  swings with how much neck the mask took, and a portrait-shaped identity image
  (tilt < 1) multiplied it further: a 300x400 head with a 0.6 reference asked
  for 136 px above. Every slack is now a fraction of the head's WIDTH, the
  stable dimension, capped at 25% sideways and 30% up.
- latent_mask_dilate was derived from mask_grow and forced to at least one
  cell, which added a whole 32 px on EVERY side -- including down, which the
  pixel grow deliberately holds at the base -- on top of a grow that already
  carried the slack. It is 0 now: _mask_to_latent reduces with MAX, so any cell
  the mask touches is fully editable already. The same head asked for 4 cells,
  128 px, before this.

Also undo the guider injections in reverse: with several chunks each injection
captured the state the previous one left, so undoing forwards restored chunk
1's specs onto the guider instead of clearing them.

Tests pin both: growth stays under 45% of the head's width across every
reference aspect and headroom, the latent dilation stays 0, and the crop box
always holds the grow plus the feather.
2026-09-01 14:29:22 +00:00
alisson-anjos 5dd563661f Name the Head Swap Sampler in the package description
The description still listed the pack as it was several nodes ago; the sampler
and LTX Multiple Controls are what people install it for now.
2026-09-01 12:42:11 +00:00
alisson-anjos f4af1cae69 Auto config from the mask, and give the guider back its options (1.37.0)
auto_config measures the subject in subject_mask -- width, height, travel, size
change, per-frame step -- and derives crop mode, crop scale, mask grow/blur,
paste feather and latent dilation from it. Every amount in this node is in
pixels, and a pixel only means something relative to how big the head is in
frame: the same 8 px is generous on a distant head and invisible on a close
one, and that mismatch is what leaves a seam.

The mask is the OLD head, so growing it by a fixed fraction only moves the
clip when the new head is wider or has more hair. The reference's proportions
are measured (identity_image's aspect against the mask's) and decide WHERE the
slack goes; its absolute size cannot be measured at all -- a cropped head
carries no scale -- so that part is identity_headroom, default 1.15. Growth is
anisotropic: up and sideways, never down into the neck and collar, which stay
the guide's own pixels. The margin between mask and crop border is a budget:
the grow takes what it needs, the feather gets half of what is left, floored --
a ramp wider than its share fades the head itself.

Two fixes for state that outlived a run:

- the transformer_options written into the guider are now undone in a finally.
  The guider belongs to another node and survives in the execution cache, so
  the reference latents stayed reachable (VRAM never freed) and a later run
  that did not overwrite the same keys sampled with the previous clip's specs.
- chunked clips move each sampled chunk to the offload device as it comes out,
  instead of holding all of them on the sampling device.

40 tests, including that each side grows by exactly its own amount (the pad is
mirrored: padding the top grows the mask downward) and that the feather never
reaches past the margin it has.
2026-09-01 12:40:30 +00:00
alisson-anjos 9cdd19ca2d Hard denoise mask, and dilation in latent cells (1.36.0)
The node was feeding one mask to two jobs with opposite needs. A soft edge
hides the seam when compositing, but in the denoise mask it means partial
denoising: the original latent, and with it the original identity, blends
back in exactly at the edge of the head. mask_hard_for_inpaint (default on)
binarises the mask for the sampler and leaves the soft one for the paste.

latent_mask_dilate grows the mask by whole latent cells after the reduction,
which is the scale that actually decides whether the head sits inside
editable blocks -- 32 px per cell, 8 video frames per latent frame. On a test
mask one cell went from 11% to 25% of the grid. latent_mask_dilate_frames
does the same along time. The latent_mask output reflects both.
2026-08-31 22:30:00 +00:00
alisson-anjos d6f3714e98 Recommend the external decode in the help page (1.35.1)
Internal decoding exists only because the paste-back needs pixels. For
anything past a single pass the graph's own VAE Decode (Tiled) is the better
tool -- tunable, visible, already there -- so the docs now say so and point
at decode: none plus the paste-back node.
2026-08-31 21:44:02 +00:00
alisson-anjos 2097d6f4b8 Add tiled and skippable VAE decode (1.35.0)
A second pass after a 2x upscaler leaves a latent the full decode cannot
hold: VRAM pins at 99%, the GPU oscillates between 0 and 100 as tensors
shuttle, and it reads as a hang. Graphs solve this with VAE Decode (Tiled);
the node had no way to.

decode: full | tiled | none, with the usual tile/overlap and temporal
controls. none returns the latent alone, for feeding your own tiled decode
downstream -- and then there is nothing to paste back, so compositing is
skipped too.
2026-08-31 21:43:30 +00:00
alisson-anjos 982ad94324 Make crop_bboxes a socket, not four widgets (1.34.2)
Without forceInput the BOUNDING_BOX input renders as editable x/y/width/height
fields, inviting a hand-typed box -- which would paste every frame into the
same fixed rectangle instead of following the planned path.
2026-08-31 21:20:52 +00:00
alisson-anjos 3d0160b0d5 Declare DESCRIPTION and the web folder so the help button shows (1.34.1)
The frontend builds node help as /extensions/<pack>/docs/<NodeID>.md, served
from the pack's registered web dir -- which ComfyUI only registers when the
folder exists at boot, and which the registry path reads from [tool.comfy]
web. Declared both, plus DESCRIPTION on each class, since the classic API
ignores docstrings.
2026-08-31 21:02:07 +00:00
alisson-anjos 1ba2131b04 Allow deferring the paste-back for a second pass (1.34.0)
With cropping on, the sampled latent is the crop's, which is exactly what a
refinement pass wants: the face has pixels there and the surrounding frame
never needed a second pass. But the node composited before returning, so
there was no way to get between the two.

paste_back: off now keeps images and latent in crop space, crop_bboxes comes
out alongside them, and the new BFS Head Swap Paste Back node composites at
the end with the same edge-aware feather and mask confinement.
2026-08-31 21:00:40 +00:00
alisson-anjos b440176096 Edge-aware feather and mask-confined paste-back (1.33.1)
Two behaviours the reference uncrop has and ours did not.

Sides of the crop that sit on the image edge are no longer feathered: there
is nothing outside to blend into, and fading there leaves a washed band along
the frame border.

paste_confine_to_mask (default on) pastes only inside the mask rather than
the whole crop rectangle, so whatever the model invented in the crop's
background never reaches the final frame.
2026-08-31 20:45:52 +00:00
alisson-anjos d96761df1f Seed the latent from the guide when inpainting (1.33.0)
Inpainting preserves the INITIAL latent outside the mask. The node was
handing it the empty latent, so everything outside the mask came back grey
instead of the source video -- the encode that a normal inpaint graph does
before Set Latent Noise Mask was simply missing.

The guide chunk is now VAE-encoded and becomes the sampling latent whenever
a mask is in play. On AV latents the audio stream from the connected latent
is kept and only the video stream is replaced.
2026-08-31 20:44:58 +00:00
alisson-anjos 83cd826963 Read the planner's boxes as dicts, and outline them per frame (1.32.2)
The vendored planner emits boxes as {"x","y","width","height"} dicts, not
(x,y,w,h) tuples, so tracked/zoomed died with KeyError: 0 on paste-back.
Both shapes are accepted now.

mask_over_source also draws the moving box, one per frame, so the planned
modes can be watched the same way the static one could.
2026-08-31 20:43:21 +00:00
alisson-anjos 7433c8457e Serve the help page from web/docs, and add mask_strength (1.32.1)
The frontend fetches node help from /extensions/<module>/docs/<NodeID>.md,
which is served out of WEB_DIRECTORY -- a docs/ folder at the pack root is
never reached. Moved to web/docs/.

mask_strength controls how completely the masked region is replaced. It is
NOT how you get expressions: those arrive through the aligned guide, which
carries the whole source performance regardless of the mask. Below 1.0 the
original latent blends back, keeping the original geometry and dragging the
original identity along with it -- an average of both faces. Documented as
such so it is not reached for by mistake.
2026-08-31 20:39:16 +00:00
alisson-anjos c19d2bb8a1 Vendor the crop planner so the node stands alone (1.32.0)
The sampler no longer reaches into a sibling pack at runtime: the crop path
planner and its helpers now live here as bfs_crop_planner.py and
bfs_subject_crop.py, copied from drozbay/MaskVidExperiments (d98cc89, v0.2.0).
Both packs are GPL-3.0, which is what makes the copy legal; attribution is in
NOTICE and in each file's header. Only the ComfyUI node classes were dropped
-- they stay in the original pack, where they belong.

tracked/zoomed therefore work out of the box now, and the per-frame paste-back
is ours rather than a call into their uncrop node.

Also adds the node's help page (docs/BFSHeadSwapMaskedSampler.md, the '?' on
the node): what each crop mode does, why the mask is native inpainting rather
than a LoRA feature, why latent_mask is the output to check first, how to size
the latent to the crop, and the order to switch things on when a run looks
wrong. scipy is now a declared dependency (the planner uses ndimage).
2026-08-31 20:34:39 +00:00
alisson-anjos 5203e8d30c Resize the sampled crop to the box before pasting it back (1.31.1)
The sampled size comes from the connected latent, and the zoomed crop mode
rescales crops by design, so the result is not guaranteed to match the box
it came from -- pasting then fails on a shape mismatch. Resize first, and
report the size the latent should be to avoid the resample entirely.
2026-08-31 20:29:43 +00:00
alisson-anjos 72cb9fe8fa Add inspection outputs: mask over source, cropped guide, crop and latent masks (1.31.0)
Debugging this pipeline blind is the slow way. Five outputs now say what the
node actually did:

mask_over_source  the mask painted on the ORIGINAL frames with the crop box
                  outlined -- is the mask where you think it is?
cropped_guide     exactly what the model was fed
crop_mask         the mask after grow/blur, in the crop's pixel space
latent_mask       the mask as the SAMPLER sees it: max-reduced to the latent
                  grid and upsampled back. One frame per latent frame, which
                  is the real resolution of the edit and where a thin mask
                  disappears entirely
debug             crop mode and box, mask ops, tiling
2026-08-31 20:08:19 +00:00
alisson-anjos 5d5703238b Inject transformer_options into the guider, and decode only the video stream (1.30.3)
CFGGuider captures model_patcher.model_options BY REFERENCE when it is
constructed (comfy/samplers.py:937), so swapping guider.model_patcher does
not propagate -- the guider keeps sampling with the dict it captured, and
the reference specs LTXMultipleControls wrote on the clone never reach the
forward. That is why the guide had no effect at all despite the node
reporting two active slots. The keys are now mutated into the guider's own
transformer_options in place, the way the Edit Anything looping sampler
already does it.

LTX-2.5 latents are AV, and the video VAE takes the video stream alone --
what LTXVSeparateAVLatent does in the stock graphs.
2026-08-31 20:06:23 +00:00
alisson-anjos 63dad57cc2 Take the latent from the graph instead of fabricating one (1.30.2)
LTX-2.5 latents are AV: video and audio nested together, which is why the
working graphs carry an LTXVSeparateAVLatent. Building a plain
[1,128,t,h,w] tensor hands the model a structure it does not expect, and
the guide stops reaching it -- the run returns the reference face and none
of the source clip.

The node now takes an optional latent (connect EmptyLTXVLatentVideo, sized
to the crop when cropping), slices it per chunk when it is video-only, and
refuses chunked sampling on a nested AV latent rather than corrupting it.
The old fabricated latent stays as a last resort, with a warning.
2026-08-31 20:00:28 +00:00
alisson-anjos 87355cb291 Swap the guider's model_patcher per chunk, and decode the tensor (1.30.1)
Two bugs from the first cut, both caught on the first real run.

_sample_chunk samples through guider.model_patcher, so passing the patched
clone as the model argument did nothing: the reference specs sat in
transformer_options that the forward never read, and guide + identity were
silently inert -- sampling ran to completion and ignored both. The pack
already had _set_guider_conds for exactly this; use it.

vae.decode takes the sample tensor, not a latent dict.
2026-08-31 19:55:09 +00:00
alisson-anjos 3e77a11908 Add the Head Swap masked sampler: stable crop, native mask inpainting, temporal loop (1.30.0)
The LoRA keeps doing only its job -- guide on source_id 1, identity on
source_id 2. The mask never reaches it: it becomes latent["noise_mask"],
which the guider consumes as denoise_mask, so only the masked region is
denoised and the original face stays visible to the model.

Masks are reduced to the latent grid with MAX rather than ComfyUI's
trilinear resize, which blurs a mask across frames and lets the original
content bleed through the edit.

Crop exists for the case a LoRA cannot fix: a face too small to carry
identity. tracked/zoomed borrow drozbay/MaskVidExperiments' planner when
that pack is installed -- its boxes hold still through mask noise and
occlusion, and a jittering crop reads as camera motion to a video model.
Without it, one static box around the subject's whole travel.

Helpers are unit-tested; the sampling path still needs a GPU run.
2026-08-31 19:38:25 +00:00
alisson-anjosandClaude Opus 5 0a25538692 Do the layout maths in float, not in the coordinate dtype (1.29.3)
ComfyUI's pixel coords are int64 -- latent corners multiplied by integer VAE scale factors --
while centring a reference on the target's height and taking a clip midpoint are half-pixel
operations. Adding a float shift into a Long tensor in place raises "result type Float can't
be cast to the desired output type Long", which is where the sidecar layout landed next.

_apply_tass_layout now promotes both coordinate tensors to float32 for the shift and rounds
back to the caller's dtype on the way out. Rounding matters: the caller casts the reference
coords to the target grid's dtype when concatenating, and a cast truncates where rounding is
what a half-pixel centre wants.

The earlier tests missed this because they built float tensors -- test_tass_layout now runs
every layout on int64 input and checks both the dtype and that the result stays within half
a pixel of the float placement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGYHCVjHe6acihEpCRyn1K
2026-08-23 17:01:25 +00:00
alisson-anjosandClaude Opus 5 268f00c1a7 Place the sidecar reference correctly on 2.3 as well (1.29.2)
1.29.1 stopped the crash but only got the placement right on 2.5. The temporal span the
trainer writes -- bounds = (t_min, t_max) of the target clip -- is collapsed to one number
per token by the model, and the two LTX generations do it differently:

  use_middle_indices_grid=True   (2.5, and any checkpoint whose metadata declares it)
                                 -> (start + end) / 2, i.e. the middle of the clip
  use_middle_indices_grid=False  (2.3 checkpoints that predate the flag)
                                 -> the start bound alone, i.e. frame zero

So the same bounds mean "centre of the clip" on 2.5 and "pinned to frame 0" on 2.3 -- one
LoRA, two placements, and the 2.3 one is exactly the single-instant conditioning sidecar
exists to avoid. The layout now takes the model's own flag (read off the transformer in
_process_input) and writes the midpoint into both bounds when the model reads the start,
which lands the reference in the same place under either rule. Legacy [B, 3, N] coordinates
get the midpoint for the same reason.

Verified by collapsing the coordinates the way generate_freqs does and measuring the
reference against the target in the same frame: 2.5 is bit-identical to the trainer, and
2.3 matches to within half a patch (the coordinate convention's own offset, which applies
to the target's tokens equally).

tests/test_tass_layout.py covers both shapes, both collapse rules, the batch-1 reference vs
batch-2 target that crashed, the margin, and that patch spans are translated rather than
squeezed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGYHCVjHe6acihEpCRyn1K
2026-08-23 16:53:16 +00:00
alisson-anjosandClaude Opus 5 1fc84f4f37 Fix the sidecar layout for LTX-2.5 coordinates (1.29.1)
Two wrong assumptions in 1.29.0, both hit by the first real run:

  shape  -- LTX-2.5 hands out patch bounds, [B, 3, N, 2], not the one-corner-per-token
            [B, 3, N] the node was written against. The other layouts survived it by
            accident (their math is elementwise), but each was reducing over tokens only,
            so a patch's start and end bound were shifted by their own separate minima --
            which squeezes the span instead of translating it.
  batch  -- layouts run BEFORE the reference tokens are expanded to the CFG batch, so the
            target arrives [2, ...] while the reference is still [1, ...]. overlap/st_drc
            broadcast out of it; sidecar assigns in place and raised
            "The expanded size of the tensor (1) must match the existing size (2)".

_tass_axis_stats now reduces over every dimension except the T/H/W one -- tokens, the
bounds axis when present, and the batch -- which fixes both at once and keeps the older
[B, 3, N] shape working. Verified against ltx_core.conditioning.reference_layout: all four
layouts now agree with the trainer bit-for-bit on the 2.5 shape, in the exact batch-1
reference vs batch-2 target configuration that crashed.

Also passes the spec's sidecar_margin_pixels through instead of always using the default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGYHCVjHe6acihEpCRyn1K
2026-08-23 16:46:38 +00:00
Claude 1936d5dd5e Add the sidecar layout to the LTX conditioning nodes (1.29.0)
The trainer has had four reference layouts for a while — overlap, st_drc, strata
and sidecar — but the nodes only ever exposed three. Training a LoRA with the
reference in sidecar therefore had no matching inference path, which is how you
end up running a checkpoint in a layout it was never trained in.

sidecar places the reference beside the target on the W axis, vertically centred
on it, and stretches its T span to cover the target's whole clip, so an object
reference stays available for every frame instead of sitting at one instant. It
is the layout the replace_refedit recipe validated on 2.3 for exactly this role.

Mirrors ltx_core.conditioning.reference_layout, adapted to ComfyUI's [B, 3, N]
coordinate tensor instead of the trainer's [B, 3, N, 2] patch bounds, and takes
sidecar_margin_pixels like the trainer does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:56:18 +00:00
Claude 8eb8a0546c Add deterministic Edit Anything prompt fixer (1.28.0)
Two formatting rules the VLM breaks consistently, and that no amount of prompt
work fixed — five rounds of rewriting plus temperature 0 still produced the same
violations:

  no reference  -> the marker sentence must not exist, and a swap is a plain
                   Replace. The model emits Add + marker + Remove instead, which
                   is the reference form applied where it does not belong.
  reference     -> the marker may only follow a sentence starting with Add. When
                   it lands on a Replace, that pair appears in zero training
                   samples.

Both are decidable in code: the caller already knows whether it attached a
reference image, so the node takes that boolean rather than asking the model to
infer it. Add+Remove is recomposed into Replace, and Replace+marker into
Add+Remove, preserving the object description the VLM did get right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:51:52 +00:00
Claude 8ba0ea9620 Add nested-safe H3 latent saver (1.27.0)
The stock SaveLatent calls .contiguous() straight on samples["samples"], which
fails on H3 because the latent is a NestedTensor holding the video and audio
components. This unbinds them first and writes each as its own tensor, so an H3
latent can be taken out of the graph and decoded or inspected offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 02:04:42 +00:00
Claude 5331c16480 Load the VAE to GPU before direct decode (1.26.2)
The node read the device off the decoder parameters, but ComfyUI keeps the VAE on
CPU until something loads it — vae.decode() calls load_models_gpu first, and the
direct path skipped that. The result was a full ViT decode on CPU: minutes of
work, fans up, no output.

Now it calls comfy.model_management.load_models_gpu([vae.patcher]) and uses
vae.device / vae.vae_dtype like the native path, returning to the intermediate
device. Measured on a 768x1280 decode: 2.4s on cuda:0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:57:17 +00:00
Claude 16961e3905 Handle H3 nested latents in direct decode (1.26.1)
The H3 latent arrives as a NestedTensor holding the video and audio components,
so `z.dim()` raised AttributeError as soon as the node was fed a real generated
latent. The native nodes unbind and keep the first component (nodes.py:335); this
does the same, and re-adds the batch dimension that unbind drops.

Verified both paths produce identical output (MSE 0.00e+00) on the same latent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:37:45 +00:00
Claude eff6017e43 Add MiniMax-H3 direct single-frame decode node (1.26.0)
Decoding a DiT-generated H3 latent through the stock VAEDecode produces a visible
grid. The reference path does not use vae.decode() at all: both the checkpoint
card and the multimodalart/MiniMax-H3-images Space call the decoder directly.
This node is that recipe, with the four details its author flags as silently
corrupting if changed:

  1. vae.decoder(vae.post_quant_conv(z)) directly, never vae.decode(), whose
     temporal chunker cannot form a chunk from the few latent slices an image
     generation produces;
  2. decoded[:, :, -1] -- the last output slice, not index 0;
  3. latent denormalisation, z * latents_std + latents_mean;
  4. ImageNet pixel denormalisation, without which the frame comes out dark and
     over-contrasted like a tone-mapping bug.

Reconstruction check against a real image: 22.43 dB, block ratio 1.30 at the
32px patch boundary -- matching the raw decoder path measured separately, which
is what confirms the node reproduces the reference rather than approximating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:30:07 +00:00
Claude b193b6b0b1 Add Sharpest Frame / Laplacian node (1.25.0)
Ports the sharpest-frame selection from scanpe_experiments/h3_image.py, which
takes it from IF-Edit (arXiv:2511.19435): decoded frames carry different amounts
of residual motion blur, so score by Laplacian variance and keep the crispest
instead of a fixed index.

Pairs with running MiniMax-H3 as an image generator: the VAE's minimum clip is 5
pixel frames (2 latents), decode yields frames of uneven sharpness, and the best
one is not always at the same index.

The node documents a limitation found while testing it, because it is easy to
misuse: Laplacian variance is a BLUR heuristic, not a quality metric -- artifacts
are high-frequency too. On a real H3 decode it picked the frame with the WORST
PSNR of its block (19.72 dB against 23.19 for its neighbour) because that frame
was the most artifacted. It is sound when the candidates are the same content
differing only in blur, which is the IF-Edit setting; it is meaningless for
choosing among frames from different moments.

Also relevant and not obvious: the "last frame of each decoded block is the valid
one" rule (decoded[:, :, -1] in the upstream example) held for the first block
here (34.49 dB against 26.80 for its neighbour) but inverted on the second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:04:16 +00:00