Repository navigation
Expand file tree
/
Copy pathnode.py
More file actions
506 lines (465 loc) · 34.8 KB
/
Copy pathnode.py
File metadata and controls
506 lines (465 loc) · 34.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
"""Comfy Pro Alpha Generator: one node, ONYX-style. Pick a subject (person, animal, vehicle, prop) in the plate, get an
LTX-quality alpha for only that subject.
At run time the node expands into SAM3 tracks -> selection / ViTMatte / stabilize -> LTX alpha-gen (chunked)
-> [isolated plate -> LTX again] -> v12 blend -> save. Each stage is cached separately.
"""
import dataclasses
import json
import math
import os
import folder_paths
from comfy_api.latest import io
from comfy_execution.graph_utils import GraphBuilder
from . import _engine as E
from .internal import plate_fingerprint
NONE = "(none found)"
MISSING = "MISSING: " # combo placeholder when the recommended file is not installed (never fall back to a random model)
BUILTIN = "built-in empty prompt (no text encoder needed)"
LTX = "https://huggingface.co/Lightricks/LTX-2.5/blob/main/"
# widget -> (models folder, preferred file, download link): shown as tooltips and in "missing model" errors
MODELS = {
"ltx_model": ("diffusion_models", "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors",
LTX + "diffusion_models/ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors"),
"alpha_lora": ("loras", "ltx-2.5-22b-ic-lora-alpha-gen-0.9.safetensors",
"https://huggingface.co/Lightricks/LTX-2.5-22b-IC-LoRA-Alpha-Gen/blob/main/ltx-2.5-22b-ic-lora-alpha-gen-0.9.safetensors"),
# v15 perfect edge colour only (gated download: accept the licence on Hugging Face first). Not required:
# without it perfect runs as quick, so validate_inputs skips it (OPTIONAL_MODELS).
"clean_plate_lora": ("loras", "ltx-2.5-22b-ic-lora-clean-plate-1.0.safetensors",
"https://huggingface.co/Lightricks/LTX-2.5-22b-IC-LoRA-Clean-Plate"),
"text_encoder": ("text_encoders", "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors",
LTX + "text_encoders/gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors"),
"video_vae": ("vae", "ltx-2.5-video-vae-conv-bf16.safetensors", LTX + "vae/ltx-2.5-video-vae-conv-bf16.safetensors"),
"audio_vae": ("vae", "ltx-2.5-audio-vae-bf16.safetensors", LTX + "vae/ltx-2.5-audio-vae-bf16.safetensors"),
}
# Other files that are fine in a slot (a renamed copy, the low-RAM GGUF). Anything else is never auto-selected.
ALSO_OK = {
"ltx_model": lambda n: "ltx" in n and "2.5" in n and "distilled" in n,
"video_vae": lambda n: "ltx" in n and "2.5" in n and "video" in n and "conv" in n,
}
# Node packs the expansion needs: node type -> pack name (checked before running, so the error says what to install).
OPTIONAL_MODELS = {"clean_plate_lora"}
PACKS = {"LoadSAM3Model": "ComfyUI-SAM3", "LTXAddVideoICLoRAGuide": "ComfyUI-LTXVideo"}
QUALITY = ["Best (v12, 2 LTX passes)", "Fast (v7, 1 LTX pass)"]
V12, V7 = QUALITY
RENDER = ["full shot", "preview chunk"]
EDGE_COLOUR = ["quick", "perfect", "off"]
SIGMAS = "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0"
def _names(widget):
"""Model files for a widget. The LTX list also has .gguf files (low RAM/VRAM) when ComfyUI-GGUF is installed."""
names = folder_paths.get_filename_list(MODELS[widget][0])
if widget == "ltx_model":
try:
names = names + [n for n in folder_paths.get_filename_list("unet_gguf") if n not in names]
except KeyError: # ComfyUI-GGUF not installed
pass
return names
def _base(name):
return name.replace("\\", "/").split("/")[-1]
def _models(widget):
"""Files in the widget's models folder, the recommended one first. If it is not installed the first option is
'MISSING: <file>' so a new user never runs on some unrelated model by accident (a Flux VAE crashes deep inside
LTX). Re-read every time the node definitions are fetched: after adding a file, press R in ComfyUI."""
kind, prefer, _ = MODELS[widget]
names = _names(widget)
ok = ALSO_OK.get(widget, lambda n: False)
best = next((n for n in names if _base(n) == prefer), None) or next((n for n in names if ok(_base(n).lower())), None)
head = [best] if best else [MISSING + prefer]
return head + [n for n in names if n != best]
def _model_problem(widget, value):
"""Why a model slot cannot run, as a message a new user can act on (None = fine)."""
kind, prefer, url = MODELS[widget]
get = f"download {prefer} from {url} into ComfyUI/models/{kind}/, then press R"
if not value or value == NONE or value.startswith(MISSING):
return f"{widget}: not installed. {get}"
if _resolve(widget, value) not in _names(widget):
return f"{widget}: '{value}' is not in ComfyUI/models/{kind}/. {get}"
low = _base(value).lower()
if widget == "ltx_model" and "ltx" not in low:
return f"{widget}: '{value}' is not an LTX-2.5 model. {get}"
if widget == "video_vae" and "conv" not in low:
return f"{widget}: '{value}' is not the LTX-2.5 conv video VAE (only the conv VAE can encode the plate). {get}"
if widget == "alpha_lora" and "alpha" not in low:
return f"{widget}: '{value}' is not the alpha-gen IC-LoRA. {get}"
if widget == "clean_plate_lora" and "clean" not in low:
return f"{widget}: '{value}' is not the Clean Plate IC-LoRA. {get}"
return None
def _model_input(widget, label, optional=False):
kind, prefer, _ = MODELS[widget]
tip = f"Recommended: {prefer} | folder: ComfyUI/models/{kind}/ (press R after adding files)"
if widget == "ltx_model":
tip += (" | Low RAM/VRAM: LTX-2.5-Distilled-Q4_K_M.gguf (needs the ComfyUI-GGUF node pack); "
"keep models on an NVMe SSD so ComfyUI streams them from disk")
if widget == "text_encoder":
# Alpha-gen uses an empty prompt; its conditioning ships with the pack, so the encoder is optional.
files = [n for n in _models(widget) if not n.startswith(MISSING)]
return io.Combo.Input(widget, options=[BUILTIN] + files, default=BUILTIN, display_name=label, tooltip=(
f"{BUILTIN}: same result, skips loading the 13 GB encoder. Or pick {prefer} from ComfyUI/models/{kind}/"))
if widget == "clean_plate_lora":
tip += " | Only for edge colour = perfect; without it perfect runs as quick"
return io.Combo.Input(widget, options=_models(widget), display_name=label, tooltip=tip, optional=optional)
def _resolve(widget, name):
"""A saved model name that is not in the folder list (another machine keeps it in a subfolder such as
'D_drive\\...', or at the top level) resolves to the file with the same name."""
names = _names(widget)
if name in names:
return name
base = name.replace("\\", "/").split("/")[-1]
return next((n for n in names if n.replace("\\", "/").split("/")[-1] == base), name)
def _kitchen_attention():
"""Core 'Model Attention Backend' node with the Comfy Kitchen int8 kernel (ComfyUI 0.38+, Nvidia/AMD)."""
import nodes
import comfy.ldm.modules.attention as attn
return "ModelAttentionBackend" in nodes.NODE_CLASS_MAPPINGS and getattr(attn, "COMFY_KITCHEN_INT8_ATTENTION_IS_AVAILABLE", False)
def _vitmatte_options():
try:
d = folder_paths.get_folder_paths("vitmatte")
except KeyError:
d = []
found = sorted({n for p in d if os.path.isdir(p) for n in os.listdir(p) if os.path.isdir(os.path.join(p, n))})
return found + ["auto-download"]
def _ltx_chunks(g, stack, images, offset, count, starts, L, plan, attn=None, tile=None, decode="alpha"):
"""One LTX alpha-gen pass over the processed range, chunked and cross-faded (Stitch).
stack: links of the shared LTX nodes (pos, neg, vae, latent, alat, model, lora_scale, noise, sampler, sigmas)
plus attn_grow / attn_blur / attn_strength. offset: index of the processed range's first frame inside `images`.
attn: a subject MASK over the processed range -> the guide's attention_mask (v13), so LTX mattes that subject on
the real plate (plus the parts it hides, which v13 subtracts again where others are in front).
tile: (x, y, w, h) of a native-resolution tile (v14): each chunk is cut from that window of `images` inside
the guide node, and the chunks are decoded and stitched in one node (RAM: no per-chunk images are cached).
decode="rgb" (v15 clean plate): keep the colour; the chunks are decoded and stitched in one node to uint8."""
chunks = {}
for i, s in enumerate(starts):
if tile is not None: # v14: crop + guide fused, decode + stitch fused (no 1.2 GB chunk images cached)
x, y, tw, th = tile
guide = g.node("ComfyProAlpha_TileGuide", images=images, x=x, y=y, width=tw, height=th, start=offset + s,
length=L, end=offset + count, positive=stack["pos"], negative=stack["neg"],
vae=stack["vae"], latent=stack["latent"], latent_downscale_factor=stack["lora_scale"])
av = g.node("LTXVConcatAVLatent", video_latent=guide.out(2), audio_latent=stack["alat"])
guider = g.node("CFGGuider", model=stack["model"], positive=guide.out(0), negative=guide.out(1), cfg=1.0)
samp = g.node("SamplerCustomAdvanced", noise=stack["noise"], guider=guider.out(0),
sampler=stack["sampler"], sigmas=stack["sigmas"], latent_image=av.out(0))
sep = g.node("LTXVSeparateAVLatent", av_latent=samp.out(0))
crop = g.node("LTXVCropGuides", positive=guide.out(0), negative=guide.out(1), latent=sep.out(0))
chunks[f"chunk_{i}"] = crop.out(2)
continue
img = g.node("ComfyProAlpha_ChunkPad", images=images, start=offset + s, length=L, end=offset + count)
kw = {}
if attn is not None:
am = g.node("ComfyProAlpha_AttnChunk", mask=attn, start=s, length=L, end=count, grow=stack["attn_grow"],
blur=stack["attn_blur"])
kw = {"attention_strength": stack["attn_strength"], "attention_mask": am.out(0)}
guide = g.node("LTXAddVideoICLoRAGuideAdvanced" if attn is not None else "LTXAddVideoICLoRAGuide",
positive=stack["pos"], negative=stack["neg"], vae=stack["vae"],
latent=stack["latent"], image=img.out(0), frame_idx=0, strength=1.0,
latent_downscale_factor=stack["lora_scale"], crop="disabled", use_tiled_encode=False,
tile_size=256, tile_overlap=64, **kw)
av = g.node("LTXVConcatAVLatent", video_latent=guide.out(2), audio_latent=stack["alat"])
guider = g.node("CFGGuider", model=stack["model"], positive=guide.out(0), negative=guide.out(1), cfg=1.0)
samp = g.node("SamplerCustomAdvanced", noise=stack["noise"], guider=guider.out(0), sampler=stack["sampler"],
sigmas=stack["sigmas"], latent_image=av.out(0))
sep = g.node("LTXVSeparateAVLatent", av_latent=samp.out(0))
crop = g.node("LTXVCropGuides", positive=guide.out(0), negative=guide.out(1), latent=sep.out(0))
if decode == "rgb":
chunks[f"chunk_{i}"] = crop.out(2)
continue
dec = g.node("ComfyProAlpha_DecodeAlpha", samples=crop.out(2), vae=stack["vae"])
chunks[f"chunk_{i}"] = dec.out(0)
if decode == "rgb":
return g.node("ComfyProAlpha_DecodeStitchRGB", plan=plan, vae=stack["vae"], **chunks).out(0)
if tile is not None:
return g.node("ComfyProAlpha_DecodeStitch", plan=plan, vae=stack["vae"], **chunks).out(0)
return g.node("ComfyProAlpha_Stitch", plan=plan, **chunks).out(0)
class ComfyProAlphaGenerator(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ComfyProAlphaGenerator",
display_name="Comfy Pro Alpha Generator",
category="Comfy Compositing",
description="Comfy Pro Alpha Generator by Rikin Patel (Comfy Compositing). Matte ONE chosen subject with LTX-2.5 alpha-gen edge quality. Type what to find (person, dog, "
"car...), click Detect, click the subject; alt-click cuts parts, shift-click adds parts.",
enable_expand=True,
is_output_node=True,
inputs=[
io.Image.Input("images", tooltip=(
"The plate. Connect Load Video -> Get Video Components, Load Plate Sequence, or any crop/resize "
"chain. Then click 'Load plate' in the node. "
"Best quality at 1920x1088 (a 1920x1080 plate is padded to 1088 automatically): LTX alpha-gen is "
"tuned for that size. Bigger plates (4K, DCI, 2K) are matted on a 1080p proxy, with the edges "
"rendered again at native resolution (native-resolution edges). Smaller plates work, but edges "
"can come out softer.")),
io.Float.Input("fps", default=24.0, min=1.0, max=240.0, step=0.001,
tooltip="Plate frame rate (connect Get Video Components / Load Plate Sequence fps)"),
io.Int.Input("first_frame", default=1, min=0, max=10**7,
tooltip="Frame number of the first image (1001 for VFX plates): output files use it"),
io.Combo.Input("quality", options=QUALITY, default=QUALITY[0]),
io.Combo.Input("render", options=RENDER, default=RENDER[0],
tooltip="preview chunk = first 49 frames from the pick frame"),
io.String.Input("picks", default="{}", multiline=False, tooltip="Set by the picker; JSON."),
io.String.Input("filename_prefix", default="comfy_pro_alpha/shot"),
io.Combo.Input("format", options=["png16", "exr"], default="png16"),
# models (always visible)
_model_input("ltx_model", "LTX-2.5 model"),
_model_input("alpha_lora", "alpha-gen IC-LoRA"),
_model_input("clean_plate_lora", "Clean Plate IC-LoRA (perfect)", optional=True),
_model_input("text_encoder", "text encoder"),
_model_input("video_vae", "video VAE (conv)"),
_model_input("audio_vae", "audio VAE"),
io.Combo.Input("vitmatte", options=_vitmatte_options(), display_name="ViTMatte",
tooltip="Folder in ComfyUI/models/vitmatte/, or auto-download on first run"),
# advanced
io.Int.Input("chunk", default=49, min=9, max=145, step=8, display_name="frames per chunk",
tooltip="LTX renders the shot in chunks of this many frames (8n+1). 49 fits 1920x1088 on 32 GB."),
io.Int.Input("overlap", default=9, min=0, max=72, display_name="overlap frames",
tooltip="Frames shared by neighbouring chunks and cross-faded so the joins don't show. "
"More = smoother joins but more chunks (slower). 9 = a new chunk every 40 frames."),
io.Int.Input("seed", default=42, min=0, max=2**31 - 1, control_after_generate=False),
io.Int.Input("erode", default=10, min=0, max=64), io.Int.Input("dilate", default=15, min=1, max=64),
io.Int.Input("near_px", default=15, min=1, max=64, tooltip="v7 zone around other excluded subjects (Best)"),
io.Int.Input("stabilize_radius", default=2, min=0, max=6, display_name="temporal smoothing (frames)",
tooltip="Removes frame-to-frame chatter in the SAM3/ViTMatte edges: each frame is averaged "
"with this many frames before and after, warped by optical flow (occlusions are "
"ignored). 0 = off, 2 = default (5-frame window). Higher is smoother but fast "
"motion can lag or ghost, and it is slower. LTX edges are already stable."),
io.Float.Input("sam3_threshold", default=0.3, min=0.0, max=1.0, step=0.01),
io.Boolean.Input("fast_attention", default=True, tooltip=(
"Comfy Kitchen int8 attention for LTX: about 36% faster sampling, matte difference ~0.004 "
"(7x smaller than changing the seed). Turn off to use standard PyTorch attention.")),
io.Boolean.Input("overlap_mode", optional=True, default=False, display_name="handle overlapping people (v13)", tooltip=(
"For people crossing the subject (hands, arms, hair). LTX mattes the subject and everyone else on "
"the real plate (3 LTX passes instead of 2, plus a depth pass) and subtracts the others where SAM3 "
"and depth say they are in front: their fingers and curls come out crisp instead of soft. Replaces "
"the quality setting. If a limb of the subject that rests on someone goes missing, add a click "
"on it. Needs other subjects picked; without them it runs Best (v12).")),
io.Boolean.Input("v13_depth", optional=True, default=True, display_name="use depth for front/back (v13)", tooltip=(
"Depth Anything V2 Small decides who is in front where SAM3 gives a pixel to both people or "
"neither (fingers over a chest). Off = SAM3 alone (a local vote): faster, but fast thin fingers "
"in front of the subject can ghost.")),
io.Boolean.Input("hair_guide", optional=True, default=False, display_name="sharpen hair over subject (v13)", tooltip=(
"Overlap (v13) only. Where someone's hair crosses the subject, re-fit their hair matte to the "
"plate's own edges (guided filter): more single strands, cleaner gaps between curls. Can read "
"dark stripes or patterns on the subject's clothes as hair, so check the comp. Off by default.")),
io.Int.Input("hair_radius", optional=True, default=4, min=1, max=16, display_name="sharpen hair radius (px)", tooltip=(
"How far the hair sharpening looks for plate edges. Smaller = finer strands but noisier; "
"larger = smoother but picks up more clothing pattern.")),
io.Int.Input("attn_grow", optional=True, default=20, min=0, max=80, display_name="v13 attention grow (px)", tooltip=(
"How far each SAM3 mask is grown before it steers LTX's attention, so hair outside the SAM "
"outline is still matted. Tested best: 20.")),
io.Float.Input("attn_blur", optional=True, default=10.0, min=0.0, max=40.0, step=0.5, display_name="v13 attention feather",
tooltip="Feather of that attention mask. 0 = hard edge (cuts hair, ~1.5x error). Tested best: 10."),
io.Float.Input("attn_strength", optional=True, default=1.0, min=0.0, max=1.0, step=0.05, display_name="v13 attention strength",
tooltip="How strongly LTX follows the attention mask. Lower lets it drift to other people. Tested best: 1.0."),
io.Int.Input("occluder_reach", optional=True, default=20, min=0, max=60, display_name="v13 occluder reach (px)", tooltip=(
"How far past an in-front person's overlap the subtraction reaches, to catch their soft edge "
"(strands, fast fingertips). Larger can bite the subject's own thin parts. Tested best: 20.")),
io.Int.Input("limb_cap", optional=True, default=90, min=20, max=400, display_name="v13 invented-limb cap (px)", tooltip=(
"LTX also draws parts of the subject it cannot see (amodal). Anything further than this from the "
"SAM3 selection is removed (fades over the last 40 px). Raise it if real edges far from the "
"selection get cut. Tested best: 90.")),
io.Int.Input("front_smoothing", optional=True, default=3, min=1, max=9, display_name="v13 front/back smoothing (frames)",
tooltip="Frames averaged so the in-front / behind decision never pops. 1 = off. Tested best: 3."),
io.Boolean.Input("native_edges", optional=True, default=True, display_name="native-resolution edges (v14)", tooltip=(
"Plates bigger than 1920x1088: LTX also renders the subject's edges on native-resolution "
"1920x1088 tiles, for strand-level detail at the plate's own size. Roughly 2-3x the render "
"time on a 4K plate. Off: matte at 1080p and scale the alpha up (fast). Plates at 1080p or "
"smaller are not affected. Plates only a little bigger than 1080p (up to about 2300 px wide, "
"e.g. 2K) gain little detail for the full tile cost: switch it off there to save time.")),
io.Combo.Input("edge_colour", options=EDGE_COLOUR, default=EDGE_COLOUR[0], optional=True,
display_name="edge colour (v15)", tooltip=(
"quick: rebuilds the colour of the soft edges from the subject itself, so sky or grass behind "
"hair does not stay in the strands (CPU, no extra VRAM: about 2 s a frame at 1080p, 4 s a "
"frame at 4K). perfect: LTX Clean Plate rebuilds the background, which is then subtracted from "
"the edges (one extra LTX pass per chunk on the 1080p proxy, plus about 2 s a frame at 4K; "
"needs the Clean Plate IC-LoRA). off: the raw plate in the edges, as v0.4.1. The alpha is the "
"same in every mode. Saves rgba_ files next to the alpha.")),
],
outputs=[io.Mask.Output("alpha"), io.Image.Output("comp")],
hidden=[io.Hidden.unique_id, io.Hidden.dynprompt],
)
@classmethod
def validate_inputs(cls, picks, **kw):
import nodes
packs = sorted({p for t, p in PACKS.items() if t not in nodes.NODE_CLASS_MAPPINGS})
if packs:
return f"Missing node pack(s): {', '.join(packs)}. Install with the Manager (Custom Nodes Manager), then restart ComfyUI."
problems = [_model_problem(w, kw.get(w)) for w in MODELS
if not (w == "text_encoder" and kw.get(w) == BUILTIN) and w not in OPTIONAL_MODELS]
problems = [p for p in problems if p]
if problems:
return "Model setup: " + " | ".join(problems)
try:
p = json.loads(picks or "{}")
except ValueError:
return "picks is not valid JSON"
if not p.get("target"):
return "No subject picked: click the subject in the node (anything in the plate, or a detection after Detect)."
return True
@classmethod
def execute(cls, images, fps, first_frame, quality, render, picks, filename_prefix, format, ltx_model, alpha_lora, text_encoder,
video_vae, audio_vae, vitmatte, chunk, overlap, seed, erode, dilate, near_px, stabilize_radius,
sam3_threshold, fast_attention=True, overlap_mode=False, hair_guide=False,
v13_depth=True, hair_radius=4, attn_grow=20, attn_blur=10.0, attn_strength=1.0, occluder_reach=20,
limb_cap=90, front_smoothing=3, native_edges=True, edge_colour=EDGE_COLOUR[0],
clean_plate_lora=None):
p = json.loads(picks)
n_video, H, W = images.shape[:3]
ltx_model, alpha_lora, video_vae, audio_vae = (_resolve(w, v) for w, v in (
("ltx_model", ltx_model), ("alpha_lora", alpha_lora), ("video_vae", video_vae), ("audio_vae", audio_vae)))
if text_encoder != BUILTIN:
text_encoder = _resolve("text_encoder", text_encoder)
# Build the comp IMAGE only if something is wired to our comp output (a full 1080p shot is several GB).
dp, me = cls.hidden.dynprompt, cls.hidden.unique_id
comp_wired = any(v == [me, 1] or v == (me, 1)
for nid in dp.all_node_ids() for v in (dp.get_node(nid).get("inputs") or {}).values())
# Sub-nodes link straight to whatever feeds our `images` input (no copy of the batch).
src = cls.hidden.dynprompt.get_node(cls.hidden.unique_id)["inputs"]["images"]
if p.get("plate") and p["plate"] != plate_fingerprint(images):
print("[ComfyProAlpha] warning: the plate changed since the subject was picked; picks may be off")
start = int(p["target"]["frame"])
count = n_video - start
L = min(chunk, E.ltx_frames(count) if count >= chunk else 8 * math.ceil((count - 1) / 8) + 1)
if render == RENDER[1]:
count = min(count, L)
starts = E.chunk_starts(count, L, max(1, L - overlap))
if len(starts) > 24:
raise ValueError(f"{count} frames needs {len(starts)} chunks (max 24): lower 'overlap frames' or split the shot")
best = quality == V12
prefix = filename_prefix + ("_preview" if render == RENDER[1] else "")
g = GraphBuilder()
native_src = None
if E.needs_proxy(W, H): # v14: bigger than LTX's 1920x1088: matte a proxy, native tiles refine the edges
NW, NH = W, H
native_src = src
src = g.node("ComfyProAlpha_Proxy", images=native_src).out(0)
W, H = E.proxy_size(W, H)
vid = g.node("ComfyProAlpha_LoadVideo", images=src, fps=fps)
sam = g.node("LoadSAM3Model", precision="auto")
last = start + count - 1
def track(mode, frame, **prompt):
"""One SAM3 object, tracked forward from its prompt frame to the end of the processed range."""
seg = g.node("SAM3VideoSegmentation", video=vid.out(0), prompt_mode=mode, frame_idx=int(frame),
score_threshold=sam3_threshold, **prompt)
prop = g.node("SAM3Propagate", sam3_model_config=sam.out(0), video_state=seg.out(0),
start_frame=int(frame), end_frame=last, direction="forward")
return g.node("ComfyProAlpha_TrackMasks", masks=prop.out(0), start=start, count=count,
width=W, height=H).out(0)
def box_track(b):
cx, cy, w, h = b["box"]
box = g.node("SAM3CreateBox", center_x=cx, center_y=cy, width=w, height=h, is_positive=True)
return track("box", b["frame"], positive_boxes=g.node("SAM3CombineBoxes", box_1=box.out(0)).out(0))
def point_track(pt):
neg = pt.get("neg", [])
pts = g.node("SAM3CombinePoints", **{f"point_{i + 1}": g.node("SAM3CreatePoint", x=x, y=y, is_foreground=True).out(0)
for i, (x, y) in enumerate(pt.get("points") or [[pt["x"], pt["y"]]])})
kw = {"positive_points": pts.out(0)}
if neg:
kw["negative_points"] = g.node("SAM3CombinePoints", **{
f"point_{i + 1}": g.node("SAM3CreatePoint", x=x, y=y, is_foreground=False).out(0)
for i, (x, y) in enumerate(neg)}).out(0)
return track("point", pt["frame"], **kw)
def union(masks):
out = None
for m in masks:
out = m if out is None else g.node("ComfyProAlpha_MaskMax", a=out, b=m).out(0)
return out
# Target from a detection (box) or from a plain click anywhere (SAM3 point prompt, no Detect needed).
target = box_track(p["target"]) if "box" in p["target"] else point_track(p["target"])
in_range = lambda items: [x for x in items if int(x["frame"]) <= last] # parts first seen after a preview
refine_kw = {k: v for k, v in (("adds", union([point_track(a) for a in in_range(p.get("adds", []))])),
("cuts", union([point_track(c) for c in in_range(p.get("cuts", []))])),
("others", union([box_track(o) for o in in_range(p.get("others", []))]))) if v is not None}
plate = g.node("ComfyProAlpha_Plate", images=src, start=start, count=count)
ref = g.node("ComfyProAlpha_Refine", plate=plate.out(0), target=target, vitmatte=vitmatte,
erode=erode, dilate=dilate, radius=stabilize_radius, **refine_kw)
sel, refined, others = ref.out(0), ref.out(1), ref.out(2)
# LTX-2.5 alpha-gen stack, settings from kids_ltx_alphagen_1920_49f_API.json
if ltx_model.lower().endswith(".gguf"):
import nodes
if "UnetLoaderGGUF" not in nodes.NODE_CLASS_MAPPINGS:
raise RuntimeError("A .gguf LTX model needs the ComfyUI-GGUF node pack (install it with the Manager).")
unet = g.node("UnetLoaderGGUF", unet_name=ltx_model)
else:
unet = g.node("UNETLoader", unet_name=ltx_model, weight_dtype="default")
lora = g.node("LTXICLoRALoaderModelOnly", model=unet.out(0), lora_name=alpha_lora, strength_model=1.0)
model = lora.out(0)
if fast_attention:
if _kitchen_attention():
model = g.node("ModelAttentionBackend", model=model, attention="comfy kitchen attention").out(0)
else:
print("[ComfyProAlpha] Comfy Kitchen attention unavailable (update ComfyUI); using PyTorch attention")
if text_encoder == BUILTIN:
empty = g.node("ComfyProAlpha_EmptyPrompt").out(0)
cond = g.node("LTXVConditioning", positive=empty, negative=empty, frame_rate=fps)
else:
clip = g.node("CLIPLoader", clip_name=text_encoder, type="ltxv")
cond = g.node("LTXVConditioning", positive=g.node("CLIPTextEncode", clip=clip.out(0), text="").out(0),
negative=g.node("CLIPTextEncode", clip=clip.out(0), text="").out(0), frame_rate=fps)
vae = g.node("VAELoader", vae_name=video_vae)
avae = g.node("VAELoader", vae_name=audio_vae)
latent = g.node("EmptyLTXVLatentVideo", width=W + (-W) % 32, height=H + (-H) % 32, length=L, batch_size=1)
alat = g.node("LTXVEmptyLatentAudio", frames_number=L, frame_rate=int(round(fps)), batch_size=1, audio_vae=avae.out(0))
noise = g.node("RandomNoise", noise_seed=seed)
sampler = g.node("KSamplerSelect", sampler_name="euler_ancestral")
sigmas = g.node("ManualSigmas", sigmas=SIGMAS)
plan = json.dumps({"n": count, "h": H, "w": W, "chunk": L, "starts": starts})
stack = {"pos": cond.out(0), "neg": cond.out(1), "vae": vae.out(0), "latent": latent.out(0),
"alat": alat.out(0), "model": model, "lora_scale": lora.out(1), "noise": noise.out(0),
"sampler": sampler.out(0), "sigmas": sigmas.out(0),
"attn_grow": attn_grow, "attn_blur": attn_blur, "attn_strength": attn_strength}
def ltx_pass(images, offset=0, attn=None):
"""offset: index of the processed range's first frame inside `images` (the upstream plate is read
directly at full precision; the isolated plate already starts at the range)."""
return _ltx_chunks(g, stack, images, offset, count, starts, L, plan, attn)
ltx = ltx_pass(src, offset=start) # full-precision upstream frames, no copy
final_kw = {}
v13 = overlap_mode and bool({"others", "cuts"} & refine_kw.keys())
if overlap_mode and not v13: # nobody else to order against: v12 does it
print("[ComfyProAlpha] Overlap (v13): no other subjects picked, using Best (v12)")
if v13: # the target, and everyone else in one pass (fixed cost however many people are excluded)
final_kw["attn_target"] = ltx_pass(src, offset=start, attn=sel)
final_kw["attn_others"] = ltx_pass(src, offset=start, attn=others)
if v13_depth:
final_kw["depth"] = g.node("ComfyProAlpha_Depth", plate=plate.out(0)).out(0)
final_kw.update(hair_guide=hair_guide, hair_radius=hair_radius, occluder_reach=occluder_reach,
limb_cap=limb_cap, front_smoothing=front_smoothing)
elif best or overlap_mode:
iso = g.node("ComfyProAlpha_Isolate", plate=src, start=start, selection=sel, others=others,
grow=6, blur=6.0)
final_kw["ltx_isolated"] = ltx_pass(iso.out(0))
if native_src is not None:
final_kw.update(native_plate=native_src, native_start=start)
if native_edges:
config = {"W": NW, "H": NH, "start": start, "count": count, "L": L, "starts": starts,
"links": {"selection": sel, "others": others, "plate": native_src,
"stack": {k: v for k, v in stack.items() if not k.startswith("attn")}}}
edges = g.node("ComfyProAlpha_NativeEdges", selection=sel, config=json.dumps(config), plate=native_src,
others=others, model=stack["model"], positive=stack["pos"], negative=stack["neg"],
vae=stack["vae"], audio_latent=stack["alat"], lora_scale=stack["lora_scale"],
noise=stack["noise"], sampler=stack["sampler"], sigmas=stack["sigmas"])
final_kw.update(tiles=edges.out(0), coverage=edges.out(1), tile_count=edges.out(2))
final = g.node("ComfyProAlpha_Final", plate=plate.out(0), ltx=ltx, selection=sel, refined=refined,
others=others, near_px=near_px, filename_prefix=prefix, format=format,
first_frame=first_frame + start, save=True, qc=True,
comp=comp_wired and edge_colour == "off", **final_kw)
if edge_colour == "off": # v0.4.1: the raw plate in the edges
return io.NodeOutput(final.out(0), final.out(1), expand=g.finalize())
# v15: the subject's own colour in the edges, from the full-precision plate (native size when v14)
ec_kw = {}
if edge_colour == "perfect" and clean_plate_lora and _model_problem("clean_plate_lora", clean_plate_lora) is None:
# LTX-2.5 Clean Plate on the processed range (the proxy on big plates: the unmix only uses its colour,
# and LTX invents the background's fine texture anyway); same stack as alpha-gen, other LoRA
cl = g.node("LTXICLoRALoaderModelOnly", model=unet.out(0),
lora_name=_resolve("clean_plate_lora", clean_plate_lora), strength_model=1.0)
cmodel = cl.out(0)
if fast_attention and _kitchen_attention():
cmodel = g.node("ModelAttentionBackend", model=cmodel, attention="comfy kitchen attention").out(0)
cstack = {**stack, "model": cmodel, "lora_scale": cl.out(1)}
ec_kw["clean_plate"] = _ltx_chunks(g, cstack, src, start, count, starts, L, plan, decode="rgb")
ec = g.node("ComfyProAlpha_EdgeColour", images=native_src if native_src is not None else src, start=start,
alpha=final.out(0), info=final.out(2), mode=edge_colour, filename_prefix=prefix, format=format,
first_frame=first_frame + start, comp=comp_wired, others=others, **ec_kw)
return io.NodeOutput(final.out(0), ec.out(0), expand=g.finalize())
class LegacySelectiveMatte(ComfyProAlphaGenerator):
"""The node's id before the rename. Workflows saved with it still open and run; hidden from the node menu."""
@classmethod
def define_schema(cls):
return dataclasses.replace(super().define_schema(), node_id="SelectiveMatte", is_deprecated=True)