From 603a0871e422ec9b217c0cd83abd8552a3a82ded Mon Sep 17 00:00:00 2001 From: IAMCCS Date: Wed, 25 Feb 2026 14:47:12 +0100 Subject: [PATCH] Updated IAMCCS-nodes to version 1.3.5 --- CHANGELOG.md | 14 + IAMCCS_HwSupporter.md | 99 --- README.md | 29 +- __init__.py | 25 + assets/wanimagemotionpro.png | Bin 0 -> 62938 bytes docs/AUTOLINK_PAPER.md | 185 ---- docs/LOW_VRAM_VIDEO_TIPS.md | 114 --- docs/LTX2_EXTENSION_MODULE_COMPLETE_GUIDE.md | 851 ------------------- docs/LTX2_EXTENSION_NODES_GUIDE_EN.md | 394 --------- docs/WanImageMotion.md | 136 --- iamccs_ltx2_extension_module.py | 177 ++++ iamccs_qwen_vl_flf.py | 524 ++++++++++++ iamccs_wan_svipro_motion.py | 625 +++++++++++++- version.json | 2 +- web/iamccs_autolink_converter.js | 498 ++++++----- 15 files changed, 1655 insertions(+), 2018 deletions(-) delete mode 100644 IAMCCS_HwSupporter.md create mode 100644 assets/wanimagemotionpro.png delete mode 100644 docs/AUTOLINK_PAPER.md delete mode 100644 docs/LOW_VRAM_VIDEO_TIPS.md delete mode 100644 docs/LTX2_EXTENSION_MODULE_COMPLETE_GUIDE.md delete mode 100644 docs/LTX2_EXTENSION_NODES_GUIDE_EN.md delete mode 100644 docs/WanImageMotion.md create mode 100644 iamccs_qwen_vl_flf.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 994c719..1278bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # IAMCCS Nodes - Changelog +## 🆕 2026-02-24 — 🆕 Version 1.3.5 WanImageMotionPro + Motion Safety Preset + +Changes: +- Added new video node: `WanImageMotionPro` (Motion + FLF End Lock) + - Optional `end_samples` to lock the ending latent slots (FLF-style end control) +- Added `safety_preset` to motion nodes (`IAMCCS_WanImageMotion` and `WanImageMotionPro`) + - `safe` (default): enables stabilizations only when `motion > 1.15` + - `safer`: stronger stabilization for higher motion values + - `legacy`: keeps the older behavior + +Docs: +- Added `docs/wanimagemotion_instructions.md` (Simple + Pro guide + example recipes) +- Updated `docs/WanImageMotion.md` + ## 🆕 Version 1.3.4 — Video Performance + Low-RAM Tools Date: 2026-02-01 diff --git a/IAMCCS_HwSupporter.md b/IAMCCS_HwSupporter.md deleted file mode 100644 index 7bbd39f..0000000 --- a/IAMCCS_HwSupporter.md +++ /dev/null @@ -1,99 +0,0 @@ -# IAMCCS_HwSupporter - -Node pack per ComfyUI che applica in modo “auto / preset / manual” alcune impostazioni anti-OOM e speed knobs, con un report JSON in output. - -## Nodi - -### 1) HW Supporter (auto VRAM/attention/torch knobs) -- File: `iamccs_hw_supporter.py` (`IAMCCS_HwSupporter`) -- Input principale: `model` (MODEL) -- Output: `model`, `clip` (passthrough), `vae` (passthrough), `report_json` - -Posizionamento consigliato: -- Mettilo subito dopo il nodo che crea/carica il `MODEL` (e prima di LoRA/sampling). -- Se vuoi anche `vae_tiling_suggestion` nel report, collega anche `vae` in input (opzionale). - -Cosa fa: -- VRAM reserve: imposta `comfy.model_management.EXTRA_RESERVED_VRAM` (simile al nodo reservedvram). -- SageAttention: se installato, patcha l’attenzione del modello via `model.model_options["transformer_options"]["optimized_attention_override"]`. -- PyTorch knobs: `torch.backends.cuda.matmul.allow_fp16_accumulation`, TF32. -- (Opzionale) `torch.compile`: prova a compilare `model.model.diffusion_model` (attenzione: può aumentare picco VRAM al primo run). -- Nel `report_json` include anche `vae_tiling_suggestion` (tile_size/overlap consigliati) basati su VRAM rilevata. -- Se `console_log=true` stampa una riga riassuntiva nel terminale (e i warning). - -### 2) VRAM Cleanup (unload + empty cache) -- File: `iamccs_hw_supporter.py` (`IAMCCS_VRAMCleanup`) -- Utility node per forzare `unload_all_models()` + `soft_empty_cache()` (più `gc.collect()` e `torch.cuda.empty_cache()`). - -### 3) VAE Decode Tiled (safe, optional cleanup) -- File: `iamccs_hw_supporter.py` (`IAMCCS_VAEDecodeTiledSafe`) -- Wrapper di `vae.decode_tiled(...)` con tile/overlap e supporto chunk temporale (video VAE). -- Opzione `cleanup_before_decode` per ridurre i picchi VRAM quando il decode arriva dopo il sampling. -- Nuova opzione `tiling_mode`: - - `auto`: sceglie automaticamente `tile_size` e `overlap` in base alla VRAM rilevata (conservativo, anti-OOM) - - `manual`: usa i valori inseriti a mano - -## Preset consigliati (12GB VRAM / 32GB RAM) -Impostazione pratica (conservativa): -- `profile`: `12GB_VRAM_32GB_RAM` -- `reserved_vram_gb`: 1.25 (oppure 1.5 se spesso in OOM) -- `sage_attention`: `auto` (se disponibile) -- `torch_compile_mode`: `off` (in genere più stabile su low-vram/offload) -- `fp16_accumulation`: `auto` -- `tf32`: `auto` - -## Note importanti -- `PYTORCH_CUDA_ALLOC_CONF`: in genere va impostato **prima** di avviare ComfyUI per influenzare l’allocator. Il nodo riporta un warning/nota, ma non “garantisce” di cambiare l’allocator a runtime. -- `torch.compile`: in molti setup low-vram/offload può dare instabilità o aumentare il picco VRAM (soprattutto al primo run). Usalo solo se hai margine. - -## Suggerimento pratico (pipeline 12GB) -- Sampling → (opzionale) `VRAM Cleanup` → `VAE Decode Tiled (safe)` con `tiling_mode=auto` e `cleanup_before_decode=true` se sei al limite. - -## Debug -Se qualcosa non funziona: -- guarda `report_json` (warnings + applied). -- prova a disabilitare SageAttention o `torch.compile`. -- inserisci `VRAM Cleanup` tra fasi pesanti (es. prima del VAE decode). - -## Crash Triton su Windows (libtriton.pyd / 0x80000003) -Se vedi un hard-crash tipo `libtriton.pyd` + `Exception Code: 0x80000003`, non è un OOM: di solito è un crash interno Triton/MLIR. - -Mitigazioni consigliate: -- In `IAMCCS_HwSupporter`: `torch_compile_mode = off`. -- In `IAMCCS_HwSupporter`: evita modalità SageAttention basate su Triton. - - usa `sageattn_qk_int8_pv_fp16_cuda` (consigliato) oppure `disabled`. -- Riavvia ComfyUI dopo i cambi (i crash Triton non sono “recoverable”). - ---- - -# HW Probe & Apply (English) - -IAMCCS provides a **Hardware Probe** endpoint and UI buttons to automatically recommend and apply settings. - -## What you get - -- Backend endpoint: `GET /api/iamccs/hw_probe` -- Optional query params (best-effort context): `width`, `height`, `frames`, `fps` -- Frontend buttons (added to several IAMCCS nodes): - - **Probe HW & Apply**: updates widgets immediately (visible in real-time) - - **Copy HW report**: copies the full JSON report - -## Nodes supported by the button - -- `IAMCCS_HwSupporter` -- `IAMCCS_HwSupporterAny` -- `IAMCCS_SamplerCustomAdvancedWindowed` -- `IAMCCS_VAEDecodeTiledSafe` - -## Tips - -- The hw probe uses heuristics; best values still depend on your resolution and clip length. -- For long videos, the most important VRAM lever is **temporal chunking** (`temporal_size`). - -### torch.compile on Windows - -- Default is `torch_compile_mode=off` (safest). -- If you set `torch_compile_mode=auto`, the node will attempt compilation (internally uses a conservative mode, typically `reduce-overhead`). -- On Windows, torch.compile may still hard-crash depending on Torch/Inductor/driver; if you get hard crashes, switch back to `off`. - -See `LOW_VRAM_VIDEO_TIPS.md` for practical guidance. diff --git a/README.md b/README.md index b90847d..39d416f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,21 @@ ### Category: ComfyUI Custom Nodes ### Main Feature: Fix for LoRA loading in native WANAnimate workflows + general nodes 4 ComfyUI -Version: 1.3.4 +Version: 1.3.5 + +## 🆕 Motion Nodes Update (2026-02-24) + +This update extends the WAN SVI Pro motion toolset: + +- New node: `WanImageMotionPro (Motion + FLF End Lock)` + - Adds optional `end_samples` end-lock (FLF-style) on top of motion continuity. +- New artifact-mitigation widget on both motion nodes: `safety_preset` + - `safe` (default): activates stabilizations only when `motion > 1.15` + - `safer`: stronger stabilization for higher motion values + - `legacy`: keeps the older behavior + +![[Node piece](assets/wanimagemotionpro.png)](https://github.com/IAMCCS/IAMCCS-nodes/blob/main/assets/wanimagemotionpro.png) + # UPDATE VERSION 1-3-4 @@ -35,16 +49,13 @@ Highlights (EN): - Backward-compatible input ordering preserved for older workflows. - Frontend quality-of-life: - - Bus Group “Hide options” now persists across sessions. + - Bus Group with MACRO settings. - HW probe apply is user-controlled (overwrite vs fill-missing) and preset sync can be disabled to keep manual tuning. - MultiSwitch (frontend + workflow UX): `MultiSwitch (dynamic inputs)` (`IAMCCS_MultiSwitch`) - Active-link indicator: visually shows which input is currently connected/used. - Input rename: you can rename inputs to keep complex graphs readable (especially when routing MANY signals). -Docs: -- Low VRAM Video Tips: `LOW_VRAM_VIDEO_TIPS.md` - --- # UPDATE VERSION 1-3-3 @@ -62,10 +73,6 @@ Highlights (EN): ![[Node piece](assets/extension.png)](https://github.com/IAMCCS/IAMCCS-nodes/blob/main/assets/extension.png) -Docs: - -- LTX-2 Extension Module (EN/IT): `LTX2_EXTENSION_MODULE_README.md` -- LTX-2 Nodes Guide: `LTX2_EXTENSION_NODES_GUIDE_EN.md` GGUF / OOM tips: - If you use `IAMCCS_GGUF_accelerator` and you are close to the VRAM limit, consider PyTorch allocator tuning to reduce fragmentation (must be set **before** launching ComfyUI). @@ -140,8 +147,9 @@ Highlights: - Motion modes: apply boost to `prev_samples` only or all non-first latents. - VRAM profiles: normal / chunked / per-frame loop / CPU offload for memory-constrained systems. - `include_padding_in_motion` toggle: enables motion boost on padded frames when anchor has single frame (T=1). +- `safety_preset` (safe defaults for higher motion): helps reduce color artifacts and seam degradation when pushing `motion`. - Comprehensive logging with warnings when motion_range is empty. -- Full documentation: `WanImageMotion.md` +- Full documentation: `docs/WanImageMotion.md` and `docs/wanimagemotion_instructions.md` - Removed the previously included external-model LoRA loader node and related documentation. ### New Node: IAMCCS WanImageMotion @@ -158,6 +166,7 @@ Inputs: - `include_padding_in_motion`: enable to apply motion on padded frames - `vram_profile`: memory optimization strategy - `latent_precision`: dtype control (auto/fp16/fp32) +- `safety_preset`: `safe` / `safer` / `legacy` (artifact mitigation when `motion > 1.15`) - `add_reference_latents`: optional conditioning stabilization - Optional `prev_samples`: previous latents for motion continuity diff --git a/__init__.py b/__init__.py index b431cae..3d10113 100644 --- a/__init__.py +++ b/__init__.py @@ -43,10 +43,12 @@ from .iamccs_ltx2_extension_module import ( IAMCCS_LTX2_ReferenceImageSwitch, IAMCCS_LTX2_ReferenceStartFramesInjector, IAMCCS_LTX2_FrameCountValidator, + IAMCCS_LTX2_FirstLastFramesController, ) from .iamccs_wan_svipro_motion import ( IAMCCS_WanImageMotion, + WanImageMotionPro, ) from .iamccs_autolink import ( @@ -84,6 +86,11 @@ from .iamccs_hw_probe_node import ( IAMCCS_HWProbeRecommendations, ) +from .iamccs_qwen_vl_flf import ( + IAMCCS_QWEN_VL_FLF, + IAMCCS_QWEN_VL_FLF_Advanced, +) + # Nodi principali NODE_CLASS_MAPPINGS = { "IAMCCS_WanLoRAStack": IAMCCS_WanLoRAStack, @@ -113,7 +120,10 @@ NODE_CLASS_MAPPINGS = { "IAMCCS_LTX2_ReferenceImageSwitch": IAMCCS_LTX2_ReferenceImageSwitch, "IAMCCS_LTX2_ReferenceStartFramesInjector": IAMCCS_LTX2_ReferenceStartFramesInjector, "IAMCCS_LTX2_FrameCountValidator": IAMCCS_LTX2_FrameCountValidator, + "IAMCCS_LTX2_FirstLastFramesController": IAMCCS_LTX2_FirstLastFramesController, "IAMCCS_WanImageMotion": IAMCCS_WanImageMotion, + "WanImageMotionPro": WanImageMotionPro, + "IAMCCS_WanImageMotionPro": WanImageMotionPro, "IAMCCS_SetAutoLink": IAMCCS_SetAutoLink, "IAMCCS_GetAutoLink": IAMCCS_GetAutoLink, @@ -135,6 +145,12 @@ NODE_CLASS_MAPPINGS = { "IAMCCS_VAEDecodeToDisk": IAMCCS_VAEDecodeToDisk, "IAMCCS_HWProbeRecommendations": IAMCCS_HWProbeRecommendations, + # QwenVL First/Last Frame (registered only if QwenVL is installed) + **({ + "IAMCCS_QWEN_VL_FLF": IAMCCS_QWEN_VL_FLF, + "IAMCCS_QWEN_VL_FLF_Advanced": IAMCCS_QWEN_VL_FLF_Advanced, + } if IAMCCS_QWEN_VL_FLF is not None else {}), + } NODE_DISPLAY_NAME_MAPPINGS = { @@ -163,7 +179,10 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_LTX2_ReferenceImageSwitch": "LTX-2 Reference Image Switch 🧷", "IAMCCS_LTX2_ReferenceStartFramesInjector": "LTX-2 Inject Reference Into Start Frames 🧬", "IAMCCS_LTX2_FrameCountValidator": "LTX-2 Frame Count Validator ✅ (8n+1)", + "IAMCCS_LTX2_FirstLastFramesController": "LTX-2 First/Last Frames Controller 🧲", "IAMCCS_WanImageMotion": "WanImageMotion", + "WanImageMotionPro": "WanImageMotionPro (Motion + FLF End Lock)", + "IAMCCS_WanImageMotionPro": "WanImageMotionPro (Motion + FLF End Lock)", "IAMCCS_SetAutoLink": "Set AutoLink", "IAMCCS_GetAutoLink": "Get AutoLink", @@ -185,6 +204,12 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_VAEDecodeToDisk": "VAE Decode → Disk (frames, low RAM)", "IAMCCS_HWProbeRecommendations": "HW Probe Recommendations (JSON)", + # QwenVL FLF + **({ + "IAMCCS_QWEN_VL_FLF": "QwenVL FLF — First/Last Frame Prompt 🎬", + "IAMCCS_QWEN_VL_FLF_Advanced": "QwenVL FLF — First/Last Frame Prompt (Advanced) 🎬", + } if IAMCCS_QWEN_VL_FLF is not None else {}), + } WEB_DIRECTORY = "./web" diff --git a/assets/wanimagemotionpro.png b/assets/wanimagemotionpro.png new file mode 100644 index 0000000000000000000000000000000000000000..552a3580cff6a9aaccddfebb61452041ed4f74ea GIT binary patch literal 62938 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 literal 0 HcmV?d00001 diff --git a/docs/AUTOLINK_PAPER.md b/docs/AUTOLINK_PAPER.md deleted file mode 100644 index d71f4b9..0000000 --- a/docs/AUTOLINK_PAPER.md +++ /dev/null @@ -1,185 +0,0 @@ -# IAMCCS AutoLink — Paper & Usage Instructions (EN/IT) - -## English - -### 1) What is AutoLink? -AutoLink is a **Set/Get** workflow tool designed to keep ComfyUI graphs clean and maintainable. - -Instead of long cables across the canvas, AutoLink lets you: -- Convert direct connections into **Set** (source) + **Get** (destination) pairs -- Restore the original direct connections when needed -- Apply repeatable filters (groups/blacklist), layout rules, and colors - -Everything is controlled by a dedicated “tool” node that operates on the canvas. - -### 2) Components -AutoLink is made of four logical elements: - -1. **AutoLink Converter** - - Buttons to convert/restore links. -2. **AutoLink Arguments** - - Central configuration: group filters, alignment/layout, packing/anti-overlap, colors, blacklist. -3. **AutoLink Set** - - Created near the source node: captures an output and exposes it under a key. -4. **AutoLink Get** - - Created near the destination node: retrieves the key and feeds the target input. - -### 3) Quickstart -1. Add to the canvas: - - **AutoLink Arguments** - - **AutoLink Converter** -2. Connect **AutoLink Arguments** output to the Converter `arg` input. -3. Adjust options (or keep defaults). -4. Click **Convert All Links**. - -To revert: -- Click **Restore Direct Links**. - -### 4) v1.3.3 reliability updates (important) -AutoLink Set/Get nodes are **UI tools** and are treated as **virtual** nodes. To prevent “missing required input” prompt errors, the extension automatically: -- Materializes direct links **only during prompt serialization/queue**, then restores the AutoLink wiring -- Supports nested graphs/subgraphs -- Truncates long AutoLink titles with an ellipsis (`…`) so they stay inside the node header - ---- - -## Italiano - -### 1) Cos’è AutoLink -AutoLink è un sistema **Set/Get** pensato per rendere i workflow ComfyUI più ordinati, leggibili e facili da mantenere. - -Invece di avere cavi lunghi che attraversano la canvas, AutoLink permette di: -- Convertire automaticamente collegamenti diretti in coppie **Set** (sorgente) + **Get** (destinazione) -- Ripristinare i collegamenti originali quando serve -- Gestire filtri, gruppi, layout e colori in modo ripetibile - -Il tutto è controllato da un nodo “tool” che opera sulla canvas. - -### 2) I nodi coinvolti -AutoLink è composto da quattro elementi logici: - -1. **AutoLink Converter** - - Contiene i pulsanti per convertire/ripristinare i collegamenti. -2. **AutoLink Arguments** - - Contiene tutte le opzioni: filtri per gruppi, layout, packing/anti-overlap, colori, blacklist. -3. **AutoLink Set** - - Viene creato vicino al nodo sorgente: cattura un output e lo espone con una chiave. -4. **AutoLink Get** - - Viene creato vicino al nodo destinazione: recupera la chiave del Set e alimenta l’input. - -### 3) Quickstart (workflow consigliato) -1. Aggiungi in canvas: - - **AutoLink Arguments** - - **AutoLink Converter** -2. Collega l’output di **AutoLink Arguments** all’input `arg` di **AutoLink Converter**. -3. Imposta le opzioni nel nodo **AutoLink Arguments** (anche lasciando i default). -4. Premi **Convert All Links** nel nodo **AutoLink Converter**. - -Per tornare indietro: -- Premi **Restore Direct Links** nel Converter. - -### 4) Aggiornamenti affidabilità v1.3.3 (importante) -I nodi Set/Get di AutoLink sono strumenti **lato UI** e vengono trattati come nodi **virtuali**. Per evitare errori di prompt del tipo “required input missing”, l’estensione: -- Materializza i link diretti **solo durante la queue/serializzazione del prompt**, poi ripristina il wiring AutoLink -- Supporta grafi annidati/subgraph -- Tronca i titoli AutoLink troppo lunghi con ellissi (`…`) per non farli uscire dal nodo - ---- - -## 5) Opzioni principali (Arguments) - -### 4.1 GroupExclude -- Se abilitato, **non converte** i collegamenti tra due nodi che stanno **dentro lo stesso group**. -- I collegamenti che **entrano** o **escono** dal group possono comunque essere convertiti (dipende anche da GroupInOutExclude). - -Quando usarlo: -- Se un group rappresenta un “blocco logico” che vuoi tenere cablato internamente. - -### 4.2 GroupInOutExclude -Gestisce i link che attraversano un confine di group: -- `None`: nessuna esclusione. -- `ExcludeEnter`: non converte i link che **entrano** in un group. -- `ExcludeExit`: non converte i link che **escono** da un group. -- `ExcludeBoth`: combina entrambe. - -### 4.3 Align mode -Determina come vengono posizionati Set/Get dopo la conversione e quando fai relayout. - -Opzioni principali: -- `TopToDown`, `BottomToTop`, `CenterUpDown`, `CenterDownUp` -- `AlignX_Right`, `AlignX_Left` -- `Columns_Down`, `Columns_Up` -- `Rake_Down`, `Rake_Up` -- **`Proportional`** (consigliato per layout “come i cavi”) - -#### Align = Proportional (come nell’immagine) -Con `Proportional`, Set e Get vengono agganciati alla **stessa altezza (Y)** del relativo connettore (slot) del nodo: -- Set: si allinea alla Y dello **slot di output** sorgente -- Get: si allinea alla Y dello **slot di input** destinazione - -In caso di collisioni, mantiene la Y e cerca spazio spostandosi orizzontalmente. - -### 4.4 Packing mode -Controlla l’anti-overlap durante posizionamento e relayout: -- `AvoidAll`: evita sovrapposizioni con tutti i nodi. -- `AvoidNonAutoLink`: evita solo i nodi non-AutoLink (Set/Get possono compattarsi fra loro). - -### 4.5 SeparateCol + colori -- `SeparateCol`: se attivo, permette di usare colori diversi per Set e Get. -- `AutoLinkColor`: colore base (Set). -- `AutoLinkColorGet`: colore dei Get (solo se SeparateCol è attivo). - -### 4.6 ColorTitles -Cambia il colore del testo del titolo dei nodi AutoLink: -- `White` -- `Black` -- `Auto` - -### 4.7 Blacklist (ID e Types) -AutoLink permette di escludere nodi dalla conversione: - -- `all_nodes_sel`: - - OFF: la blacklist lavora per **tipo** (`[TYPE] ...`) - - ON: la blacklist lavora per **ID singolo nodo** - -- `add_to_blacklist`: - - Scegli un nodo (ID) o un tipo. - -- `blacklist_mode` (solo per nodi singoli): - - `both`: esclude link dove il nodo è sorgente o destinazione - - `only_output`: esclude solo quando il nodo è sorgente (output) - - `only_input`: esclude solo quando il nodo è destinazione (input) - -- `EXECUTE`: - - Applica davvero l’inserimento (o l’update della modalità) e poi pulisce i widget. - -- `blacklist_view`: - - Elenco leggibile: `id - nome nodo - (modalità)` e `[TYPE] ...`. - - Selezionare una voce **non rimuove nulla**. - -- `remove_blacklist`: - - Rimuove la voce attualmente selezionata in `blacklist_view`. - ---- - -## 6) Best practices -- Prima di convertire “tutto”, imposta la blacklist per escludere nodi che vuoi lasciare cablati. -- Usa `GroupExclude` per mantenere “blocchi” interni puliti. -- Usa `Proportional` quando vuoi un layout che segua visivamente l’ordine degli slot (come routing naturale dei cavi). -- Se la canvas è molto piena, prova `PackingMode = AvoidAll`. - ---- - -## 7) Troubleshooting -- **Convert All Links non sembra fare nulla**: - - Verifica che `AutoLink Arguments` sia collegato all’input `arg` del Converter. - - Controlla blacklist e filtri group. -- **Nodi sovrapposti**: - - Prova `PackingMode = AvoidAll`. - - Cambia align mode o usa relayout cambiando `align_mode`. - ---- - -## 8) Documentazione correlata -- AUTOLINK_README.md -- AUTOLINK_TECHNICAL_PAPER.md diff --git a/docs/LOW_VRAM_VIDEO_TIPS.md b/docs/LOW_VRAM_VIDEO_TIPS.md deleted file mode 100644 index dab699a..0000000 --- a/docs/LOW_VRAM_VIDEO_TIPS.md +++ /dev/null @@ -1,114 +0,0 @@ -# IAMCCS Nodes – Low VRAM Video Tips - -This doc describes the low-VRAM features added to IAMCCS nodes for LTX video workflows. - -## 1) Hardware Probe + One-Click Apply - -IAMCCS exposes a small backend endpoint: - -- `GET /api/iamccs/hw_probe` -- Optional query params: `width`, `height`, `frames`, `fps` - -The IAMCCS UI extension adds buttons to several nodes: - -- **Probe HW & Apply** – reads your current GPU/RAM and (best-effort) reads the workflow context (width/height/frames/fps). It then applies recommended widget values immediately. -- **Copy HW report** – copies the full JSON report to clipboard. - -Notes: -- Recommendations are heuristics. Final best values depend on the model, resolution, and clip length. - -Frontend control (not rigid): -- **HW probe apply mode** - - `overwrite`: always overwrite widgets with recommended values - - `fill_missing`: only fills empty fields (does not clobber manual tuning) -- **Preset sync (profile → widgets)** (on `IAMCCS_HwSupporter` / `IAMCCS_HwSupporterAny`) - - When ON: changing `profile` updates the other widgets to match the preset. - - When OFF: you keep full manual control; profile changes won’t overwrite your values. - -## 2) VAE Decode Tiled Safe (Video) - -Node: -- `VAE Decode Tiled (safe, optional cleanup)` (`IAMCCS_VAEDecodeTiledSafe`) - -Tips: -- For long videos, the most important VRAM control is **temporal chunking** (`temporal_size`). -- If you see CUDA OOM during decode, reduce: - - `tile_size` - - `temporal_size` - - keep `overlap` and `temporal_overlap` small but non-zero - -The HW probe can also recommend values for VAE decode based on: -- GPU VRAM -- width/height -- frames/fps (if detected) - -## 3) Debug / Verification - -Where to look: -- **ComfyUI server console**: - - `/api/iamccs/hw_probe` logs a short line whenever the button is used. -- **Browser devtools console**: - - the UI prints the full hw probe JSON under `[IAMCCS HW Probe]`. - -If the button updates widgets but values get overwritten: -- ensure you clicked the button last (after changing profile/preset), -- or disable any profile auto-sync if you prefer manual tuning. - -## 4) Recommended Workflow Pattern (Low VRAM) - -Typical ordering: -- GGUF model loader -- `IAMCCS_GGUF_accelerator` -- `IAMCCS_HwSupporter` (or `IAMCCS_HwSupporterAny`) -- sampler -- VAE decode tiled safe - -## 5) VAE Decode → Disk (True Low-RAM Mode) - -New node: -- `VAE Decode → Disk (frames, low RAM)` (`IAMCCS_VAEDecodeToDisk`) - -What it does: -- Decodes **one frame at a time** and writes frames to disk, instead of keeping the full `IMAGE` batch in RAM. -- This is the most reliable way to avoid CPU OOM on long clips when you still want full-resolution outputs. - -When to use it: -- Very long videos (hundreds of frames) -- Low system RAM (or heavy multitasking) -- When `VAEDecodeTiled` still spikes CPU allocator memory - -Tip: -- Keep `cleanup_between_frames=true` if you’re tight on VRAM. -- Use PNG for best quality; use JPG if disk size is a problem. - -## 6) GGUF Accelerator – Safer “move_patches_now” - -`IAMCCS_GGUF_accelerator` now supports: -- `move_policy`: `all_or_nothing` / `partial_small_first` / `partial_large_first` -- `leave_free_vram_mb`: how much VRAM to keep free during eager patch moves - -Practical guidance: -- **8GB VRAM**: `move_policy=partial_small_first`, `leave_free_vram_mb=1500` (best chance to avoid OOM) -- **12–16GB VRAM**: `all_or_nothing`, `leave_free_vram_mb=1200` -- **24GB+ VRAM**: `all_or_nothing`, `leave_free_vram_mb=1024` (fastest) - -## 7) Presets (Low / Normal / High) - -These are sane starting points for LTX-style video workflows (no windowing): - -### Low (8GB VRAM or low RAM) -- Sampler: `IAMCCS_SamplerAdvancedVersion1` with `disable_progress=true`, `cleanup=true` -- GGUF: `mode=auto_oom_safe`, `patch_on_device=true`, `move_patches_now=true`, `move_policy=partial_small_first`, `leave_free_vram_mb=1500` -- VAE: prefer `IAMCCS_VAEDecodeTiledSafe` with smaller `tile_size` and `temporal_size=64` -- If CPU RAM is the limiter: use `IAMCCS_VAEDecodeToDisk` - -### Normal (12–16GB VRAM, 32GB RAM) -- Sampler: `disable_progress=true`, `cleanup=false` -- GGUF: `move_policy=all_or_nothing`, `leave_free_vram_mb=1200` -- VAE: `IAMCCS_VAEDecodeTiledSafe` with `tiling_mode=auto` (or manual: `tile_size≈384–512`, `temporal_size=64–96`) - -### High (24GB+ VRAM, 64GB+ RAM) -- Sampler: `disable_progress=true`, `cleanup=false` -- GGUF: `all_or_nothing`, `leave_free_vram_mb=1024` -- VAE: you can often increase `tile_size` and `temporal_size=128` for faster decode - diff --git a/docs/LTX2_EXTENSION_MODULE_COMPLETE_GUIDE.md b/docs/LTX2_EXTENSION_MODULE_COMPLETE_GUIDE.md deleted file mode 100644 index 6ec34cc..0000000 --- a/docs/LTX2_EXTENSION_MODULE_COMPLETE_GUIDE.md +++ /dev/null @@ -1,851 +0,0 @@ -# LTX-2 Extension Module - Complete Technical Guide - -## Table of Contents -1. [Overview](#overview) -2. [Architecture & Workflow](#architecture--workflow) -3. [Parameters Reference](#parameters-reference) -4. [Usage Scenarios](#usage-scenarios) -5. [Advanced Features](#advanced-features) -6. [Troubleshooting](#troubleshooting) -7. [Best Practices](#best-practices) - ---- - -## Overview - -The **IAMCCS LTX-2 Extension Module** is an all-in-one node designed for iterative video extension workflows with the LTX-2 model. It combines multiple operations into a single, efficient node: - -- **Image batch merging** with configurable overlap -- **Multiple blending modes** for smooth transitions -- **Automatic frame calculations** with built-in math operations -- **LTX-2 8n+1 conformance** for start_images -- **Advanced quality features** (color matching, seam search) - -### Key Benefits -- ✅ Eliminates need for multiple separate nodes (GetImageRange, ImageBatchExtend, SimpleMath, etc.) -- ✅ Automatic 8n+1 validation prevents encoding errors -- ✅ Seamless video segment concatenation with no visible cuts -- ✅ Flexible overlap strategies for different content types -- ✅ Built-in quality enhancement features - ---- - -## Architecture & Workflow - -### Basic Extension Flow - -```mermaid -graph TB - A[Generation 1
121 frames] --> B[Extension Module] - C[Generation 2
121 frames] --> B - B --> D[extended_images
217 frames] - B --> E[start_images
17 frames 8n+1] - E --> F[Next Generation Input] - - style B fill:#2a363b,stroke:#3f5159,color:#fff - style E fill:#233,stroke:#355,color:#fff -``` - -### Complete Multi-Segment Workflow - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ITERATIVE EXTENSION LOOP │ -└─────────────────────────────────────────────────────────────────┘ - -Iteration 1: Initial Generation -┌──────────────────┐ -│ Initial Image │ 1 frame -└────────┬─────────┘ - │ - v -┌──────────────────┐ -│ LTX Sampler │ Generate 121 frames -│ (8×15 + 1) │ -└────────┬─────────┘ - │ - v -┌──────────────────┐ -│ VAE Decode │ Latent → Images -└────────┬─────────┘ - │ - v - source_images (121 frames) - │ - └──────────────────────────────────┐ - │ -Iteration 2: First Extension │ -┌──────────────────┐ │ -│ Extension │◄───────────────────────┘ -│ Module │◄── new_images (121 frames from Gen 2) -│ overlap=25 │ -│ mode=linear │ -└────────┬─────────┘ - │ - ├──► extended_images (217 frames) - │ 121 - 25 + 121 = 217 - │ - └──► start_images (17 frames) - 25 → 24 (math: a-1) → 17 (8n+1 conform) - │ - v - ┌──────────────────┐ - │ LTX Sampler │ Gen 3 (121 frames) - │ uses 17 frames │ - └────────┬─────────┘ - │ - v - new_images - │ - └──► Loop continues... - -Final Output: -┌──────────────────┐ -│ Video Segments │ -│ 217 + 217 + ... │ -│ Seamless Concat │ -└──────────────────┘ -``` - -### Internal Processing Flow - -``` -INPUT IMAGES - │ - ├─── source_images (previous generation) - │ │ - │ └─── Last 25 frames ──┐ - │ │ - └─── new_images (current generation) - │ │ - └─── First 25 frames ───┤ - │ - ┌────────v────────┐ - │ OVERLAP ZONE │ - │ 25 frames │ - └────────┬────────┘ - │ - ┌────────v────────┐ - │ BLENDING │ - │ linear_blend │ - │ Alpha: 0→1 │ - └────────┬────────┘ - │ - ┌──────────────────┴──────────────────┐ - │ │ - ┌─────────v─────────┐ ┌──────────v──────────┐ - │ extended_images │ │ start_images │ - │ Full merged batch │ │ For next iteration │ - │ (source-25+new) │ │ With 8n+1 conform │ - └────────────────────┘ └─────────────────────┘ -``` - ---- - -## Parameters Reference - -### Core Parameters - -#### `overlap_frames` (INT) -- **Default**: 10 -- **Range**: 1-256 -- **Recommended**: 25-40 for smooth transitions -- **Purpose**: Number of frames to overlap and blend between segments - -**Impact**: -- **Low (8-15)**: Fast processing, visible seams possible -- **Medium (20-30)**: ✅ **Recommended** - Good balance -- **High (40-60)**: Very smooth, but higher computational cost - -**Formula**: `extended_length = source_count - overlap + new_count` - -Example with overlap=25: -``` -source: [1...121] -new: [1...121] -overlap: 25 frames -extended: 121 - 25 + 121 = 217 frames -``` - ---- - -#### `overlap_side` (DROPDOWN) -- **Options**: `source` | `new_images` -- **Default**: `source` -- **Purpose**: Which batch to take overlap frames from - -``` -overlap_side = "source": - Take last 25 from source - Take first 25 from new - Blend source→new (recommended) - -overlap_side = "new_images": - Take first 25 from new - Take last 25 from source - Blend new→source (reverse) -``` - -**Use Cases**: -- `source`: ✅ **Standard** - Smooth forward progression -- `new_images`: Experimental - reverse blending effect - ---- - -#### `overlap_mode` (DROPDOWN) -- **Options**: `cut` | `linear_blend` | `ease_in_out` | `filmic_crossfade` | `perceptual_crossfade` -- **Default**: `linear_blend` - -### Blending Modes Comparison - -| Mode | Speed | Quality | Use Case | Formula | -|------|-------|---------|----------|---------| -| **cut** | ⚡⚡⚡ | ⭐ | Testing, no blend needed | Direct concatenation | -| **linear_blend** | ⚡⚡ | ⭐⭐⭐⭐ | ✅ **General use** | `(1-t)×src + t×dst` | -| **ease_in_out** | ⚡⚡ | ⭐⭐⭐⭐⭐ | Smooth artistic transitions | `3t² - 2t³` | -| **filmic_crossfade** | ⚡ | ⭐⭐⭐⭐⭐ | Color-accurate blending | Gamma 2.2 correction | -| **perceptual_crossfade** | ⚡ | ⭐⭐⭐⭐⭐ | Best quality (needs Kornia) | LAB color space blend | - -**Visual Comparison**: -``` -Alpha progression over 25 frames: - -linear_blend: -0.0 ████░░░░░░░░░░░░░░░░░░░░ 1.0 - │ │ - Linear interpolation - -ease_in_out: -0.0 ██▓▓▒▒░░░░░░░░░░▒▒▓▓████ 1.0 - │ Slow→Fast→Slow │ - Smooth S-curve - -filmic_crossfade: -0.0 ███▓▓▒▒░░░░░░░░░░▒▓▓███ 1.0 - │ Gamma-corrected │ - Perceptually uniform -``` - -**Recommendations**: -- **General video**: `linear_blend` (fast, reliable) -- **High quality**: `ease_in_out` (smooth, cinematic) -- **Color-critical**: `filmic_crossfade` or `perceptual_crossfade` -- **Testing/Debug**: `cut` (no blending overhead) - ---- - -#### `enable_math` (BOOLEAN) -- **Default**: `true` -- **Purpose**: Enable mathematical operations on overlap value for start_images calculation - -When enabled, applies `math_operation` to calculate the number of frames for `start_images`. - ---- - -#### `math_operation` (DROPDOWN) -- **Options**: `none` | `a-b` | `a-1` | `a+b` | `a*b` | `a/b` | `min(a,b)` | `max(a,b)` -- **Default**: `a-b` -- **Variables**: - - `a` = overlap_frames - - `b` = math_value_b (optional input) - -**Common Use Cases**: - -| Operation | Example | Result | Use Case | -|-----------|---------|--------|----------| -| `none` | overlap=25 | 25 | Direct use of overlap | -| `a-1` | 25-1 | 24 | ✅ **Standard** - LTX-2 workflow | -| `a-b` | 25-15 | 10 | Custom frame count | -| `a/b` | 25/2.5 | 10 | Proportional reduction | - -**Recommended Configuration**: -```json -{ - "overlap_frames": 25, - "enable_math": true, - "math_operation": "a-1" -} -``` -Result: 25 - 1 = 24 frames → 17 frames (after 8n+1 conform) - ---- - -#### `start_frames_rule` (DROPDOWN) -- **Options**: `none` | `ltx2_round_down` | `ltx2_nearest` -- **Default**: `none` -- **Purpose**: Enforce LTX-2 8n+1 rule for VideoVAE encoding - -### LTX-2 Frame Count Rule - -LTX-2 VideoVAE requires frame counts following the formula: **`frames = 8n + 1`** - -Valid frame counts: `1, 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105, 113, 121...` - -**Examples**: - -| Input | ltx2_round_down | ltx2_nearest | none | -|-------|----------------|--------------|------| -| 24 | 17 (8×2+1) | 17 (closer) | 24 ❌ | -| 26 | 25 (8×3+1) | 25 (closer) | 26 ❌ | -| 30 | 25 (8×3+1) | 33 (closer) | 30 ❌ | -| 17 | 17 ✅ | 17 ✅ | 17 ✅ | - -**When to Use**: -- ✅ **Always use** `ltx2_round_down` or `ltx2_nearest` when start_images feeds into a sampler -- ❌ **Never use** when output is only for preview/saving (not encoding) - -**Critical**: Without this, you'll get errors like: -``` -Error: Expected frame count 8n+1, got 24 -``` - ---- - -### Advanced Quality Parameters - -#### `color_match_mode` (DROPDOWN) -- **Options**: `none` | `luma_only` | `per_channel` -- **Default**: `none` -- **Purpose**: Match color/exposure of new_images to source_images tail - -**Use Cases**: -- **Lighting changes**: Different segments with varying brightness -- **Color shifts**: Camera auto-balance between shots -- **Consistency**: Maintain uniform look across segments - -``` -none: - source: █████████▓▓▓▓▓ (bright end) - new: ▒▒▒▒▒░░░░░░░░ (dark start) - → Visible seam - -luma_only: - Match overall brightness only - → Quick, preserves color tone - -per_channel: - Match R, G, B independently - → Best quality, may shift colors -``` - ---- - -#### `color_match_strength` (FLOAT) -- **Range**: 0.0-1.0 -- **Default**: 1.0 -- **Purpose**: Blend factor for color matching - -``` -strength = 0.0: No correction -strength = 0.5: Partial correction -strength = 1.0: Full correction -``` - ---- - -#### `seam_search_mode` (DROPDOWN) -- **Options**: `none` | `best_of_k` -- **Default**: `none` -- **Purpose**: Search for optimal seam position within overlap zone - -**How It Works**: -``` -Standard overlap (offset=0): -source: ████████████████████▓▓▓▓▓ -new: ░░░░░░░░░░░░░░░░░ - ↑ Potential seam - -Best-of-k search (k=8): -Tries offsets 0-8: -offset=0: ▓▓▓▓▓ vs ░░░░░ → score: 0.85 -offset=1: ▓▓▓▓▓ vs ░░░░░ → score: 0.72 -offset=2: ▓▓▓▓▓ vs ░░░░░ → score: 0.65 ✅ Best! -... -Chooses offset=2 (lowest discontinuity) -``` - -**Scoring Metrics**: -- Color/luma continuity (weighted by `metric_weight_color`) -- Edge continuity (weighted by `metric_weight_edges`) - -**Trade-offs**: -- ✅ Reduces visible seams -- ✅ Handles motion/camera cuts better -- ❌ Slower (tests k candidates) -- ❌ May "skip" frames from new_images - ---- - -## Usage Scenarios - -### Scenario 1: Standard Video Extension (Recommended) - -**Goal**: Extend a video smoothly without visible seams - -**Configuration**: -```json -{ - "overlap_frames": 25, - "overlap_side": "source", - "overlap_mode": "linear_blend", - "enable_math": true, - "math_operation": "a-1", - "start_frames_rule": "ltx2_round_down", - "color_match_mode": "none", - "seam_search_mode": "none" -} -``` - -**Workflow**: -1. Generate segment 1 (121 frames) -2. Extract last 17 frames (8×2+1) -3. Generate segment 2 with those 17 frames as reference -4. Extension Module merges with 25-frame overlap -5. Repeat - -**Output**: Seamless 217-frame video (then 313, 409, etc.) - ---- - -### Scenario 2: High-Quality Cinematic Extension - -**Goal**: Maximum quality with perceptual blending - -**Configuration**: -```json -{ - "overlap_frames": 40, - "overlap_side": "source", - "overlap_mode": "perceptual_crossfade", - "enable_math": true, - "math_operation": "a-1", - "start_frames_rule": "ltx2_nearest", - "color_match_mode": "per_channel", - "color_match_strength": 0.8, - "seam_search_mode": "best_of_k", - "k_search": 16 -} -``` - -**Best For**: -- Film production -- High-resolution output -- Color-critical content -- Complex lighting scenarios - ---- - -### Scenario 3: Fast Preview / Testing - -**Goal**: Quick iteration, minimal processing - -**Configuration**: -```json -{ - "overlap_frames": 10, - "overlap_side": "source", - "overlap_mode": "cut", - "enable_math": true, - "math_operation": "a-1", - "start_frames_rule": "ltx2_round_down", - "color_match_mode": "none", - "seam_search_mode": "none" -} -``` - -**Best For**: -- Testing prompts -- Workflow debugging -- Quick previews - ---- - -### Scenario 4: Lighting-Corrected Extension - -**Goal**: Handle varying lighting between segments - -**Configuration**: -```json -{ - "overlap_frames": 30, - "overlap_side": "source", - "overlap_mode": "ease_in_out", - "enable_math": true, - "math_operation": "a-1", - "start_frames_rule": "ltx2_round_down", - "color_match_mode": "luma_only", - "color_match_strength": 1.0, - "color_reference_window": 12 -} -``` - -**Best For**: -- Outdoor scenes (sun changes) -- Mixed lighting conditions -- Auto-exposure variations - ---- - -## Advanced Features - -### Two-Stage Overlap Strategy - -Replicating the "early version" workflow behavior with separate overlap values: - -```python -# Early version used: -# - overlap=10 for frame extraction -# - overlap=25 for blending - -# Extension Module equivalent: -{ - "overlap_frames": 25, # For blending - "math_operation": "a/b", # Calculate extraction - "math_value_b": 2.5, # 25/2.5 = 10 - "start_frames_rule": "ltx2_round_down" -} - -# Result: -# - Blending uses 25 frames (smooth) -# - start_images calculated from 10 → 9 → 9 frames (8×1+1) -``` - ---- - -### Custom Frame Count Calculation - -**Example**: Generate 33 frames for next iteration (8×4+1) - -```json -{ - "overlap_frames": 25, - "math_operation": "a+b", - "math_value_b": 9, // 25 + 9 = 34 - "start_frames_rule": "ltx2_round_down" // 34 → 33 -} -``` - ---- - -### Adaptive Overlap with AutoLink - -When using AutoLink for iterative loops: - -```json -{ - "overlap_frames": 25, - "autolink_overlap_in": 0, // Override if > 0 from AutoLink - // ... other params ... -} - -// Extension Module outputs: -// autolink_overlap_out → feeds next iteration's autolink_overlap_in -``` - ---- - -## Troubleshooting - -### Problem: Visible seams between segments - -**Symptoms**: Hard cuts, color shifts, motion jumps - -**Solutions**: -1. ✅ Increase `overlap_frames` to 25-40 -2. ✅ Change to `ease_in_out` or `filmic_crossfade` -3. ✅ Enable `color_match_mode = "luma_only"` -4. ✅ Try `seam_search_mode = "best_of_k"` with `k_search = 8` - ---- - -### Problem: Error "Expected 8n+1 frames" - -**Symptoms**: Workflow fails at sampler/encoder - -**Solutions**: -1. ✅ Set `start_frames_rule = "ltx2_round_down"` -2. ✅ Verify `enable_math = true` -3. ✅ Check math formula produces reasonable values -4. ❌ Don't use `start_frames_rule` if output is for preview only - ---- - -### Problem: Videos too long / memory issues - -**Symptoms**: Out of memory, slow processing - -**Solutions**: -1. ✅ Reduce `overlap_frames` to 15-20 -2. ✅ Use `overlap_mode = "linear_blend"` (faster) -3. ✅ Disable `seam_search_mode` -4. ✅ Process in smaller batches - ---- - -### Problem: Color mismatch at seams - -**Symptoms**: Brightness/hue shifts visible - -**Solutions**: -1. ✅ Enable `color_match_mode = "per_channel"` -2. ✅ Set `color_match_strength = 0.8-1.0` -3. ✅ Increase `color_reference_window` to 16-24 -4. ✅ Use `filmic_crossfade` for gamma-correct blending - ---- - -## Best Practices - -### 1. Start with Recommended Defaults - -```json -{ - "overlap_frames": 25, - "overlap_side": "source", - "overlap_mode": "linear_blend", - "enable_math": true, - "math_operation": "a-1", - "start_frames_rule": "ltx2_round_down", - "color_match_mode": "none", - "seam_search_mode": "none" -} -``` - -Then optimize based on your specific needs. - ---- - -### 2. Overlap Guidelines by Content Type - -| Content Type | Overlap | Blend Mode | Reason | -|--------------|---------|------------|--------| -| **Static scenes** | 15-20 | linear_blend | Less motion, simpler blend | -| **Camera movement** | 25-40 | ease_in_out | Smooth motion transition | -| **Fast action** | 30-50 | filmic_crossfade | Avoid motion artifacts | -| **Talking heads** | 20-30 | linear_blend | Consistent framing | -| **Nature/landscape** | 25-35 | perceptual_crossfade | Color accuracy | - ---- - -### 3. Processing Order - -Always follow this order in your workflow: - -``` -1. Initial Image - ↓ -2. LTX Sampler (8n+1 frames) - ↓ -3. VAE Decode - ↓ -4. Extension Module - ├─→ extended_images (for final output) - └─→ start_images (for next iteration) - ↓ -5. Loop back to step 2 -``` - -**Critical**: Never feed `extended_images` back into the sampler directly - always use `start_images` (conformant to 8n+1). - ---- - -### 4. Testing Workflow - -Before full production: - -1. Test with `overlap=10`, `mode=cut` (fast preview) -2. Verify no errors with `start_frames_rule = "ltx2_round_down"` -3. Increase overlap to 25, switch to `linear_blend` -4. Fine-tune with quality features if needed - ---- - -### 5. Output Validation - -Check the `report` output for each iteration: - -``` -Source: 121 frames | -Overlap (effective): 25 frames | -Start range: start_index=96, num_frames=17 | -Math: a-1 | -Start frames rule: ltx2_round_down | -Extended: 217 frames | -Extension delta: +96 frames | -Blend mode: linear_blend -``` - -Verify: -- ✅ `num_frames` is 8n+1 (9, 17, 25, 33, etc.) -- ✅ `Extension delta` is positive -- ✅ No warnings in console - ---- - -## Performance Optimization - -### Memory Usage - -| Configuration | Memory Impact | Speed | -|---------------|---------------|-------| -| overlap=10, cut | Low | ⚡⚡⚡ | -| overlap=25, linear | Medium | ⚡⚡ | -| overlap=40, ease_in_out | Medium-High | ⚡⚡ | -| overlap=40, perceptual + seam search | High | ⚡ | - ---- - -### Batch Processing Tips - -For very long videos (10+ segments): - -1. **Save intermediate results**: - ``` - Segment 1 → Save - Segment 2 → Save - ... - Final concatenation separately - ``` - -2. **Use progressive overlap**: - ``` - Segments 1-3: overlap=25 (quality) - Segments 4+: overlap=15 (speed) - ``` - -3. **Monitor VRAM**: - - Each 121-frame batch ≈ 4-8GB VRAM - - Reduce resolution if needed - ---- - -## Workflow Diagrams - -### Complete Extension Pipeline - -``` -┌────────────────────────────────────────────────────────────────┐ -│ INITIALIZATION │ -└────────────────────────────────────────────────────────────────┘ - -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Load Model │────>│ Load VAE │────>│ Load CLIP │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ - └───────────────────┴───────────────────┘ - │ - v -┌────────────────────────────────────────────────────────────────┐ -│ GENERATION LOOP START │ -└────────────────────────────────────────────────────────────────┘ - -Iteration N: -┌─────────────┐ -│ start_images│ (17 frames, 8×2+1) -│ from prev │ -└──────┬──────┘ - │ - v -┌─────────────────────────────────────────────────────────────┐ -│ SUBGRAPH: Samplers │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ VAE Encode │────>│ LTX Sampler │────>│ VAE Decode │ │ -│ │ (to latent) │ │ (121 frames)│ │ (to images) │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ - v - new_images (121 frames) - │ - └──────────────────────────┐ - │ -┌─────────────────────────────────v──────────────────────────┐ -│ Extension Module │ -│ │ -│ source_images (121) + new_images (121) │ -│ │ │ -│ v │ -│ ┌────────────────────────┐ │ -│ │ Overlap Extraction │ │ -│ │ Last 25 from source │ │ -│ │ First 25 from new │ │ -│ └────────┬───────────────┘ │ -│ │ │ -│ v │ -│ ┌────────────────────────┐ │ -│ │ Blending │ │ -│ │ Mode: linear_blend │ │ -│ │ Alpha: 0→1 over 25 │ │ -│ └────────┬───────────────┘ │ -│ │ │ -│ v │ -│ ┌────────────────────────┐ │ -│ │ Concatenation │ │ -│ │ [prefix][blend][suffix]│ │ -│ └────────┬───────────────┘ │ -│ │ │ -│ ├─────────────────────────────┐ │ -│ │ │ │ -│ v v │ -│ extended_images (217) start_images (17, 8n+1) │ -│ │ │ │ -└───────────┼─────────────────────────────┼─────────────────┘ - │ │ - v └─> Next Iteration - ┌───────────────┐ - │ CreateVideo │ - │ Concatenate │ - │ with Audio │ - └───────┬───────┘ - │ - v - ┌───────────────┐ - │ SaveVideo │ - │ Final Output │ - └───────────────┘ -``` - ---- - -### Overlap Blending Visualization - -``` -Source Batch (121 frames): -[████████████████████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] - └─ Last 25 frames ─┘ - -New Batch (121 frames): - [░░░░░░░░░░░░░░░░░░░░░░░░░████████████████████████████████████████████████████] - └─ First 25 frames ─┘ - -Blending Zone (25 frames with linear alpha): -Frame: 1 2 3 4 5 ... 23 24 25 -Alpha: 0.00 0.04 0.08 0.12 0.16 ... 0.92 0.96 1.00 - ████ ███▓ ███▒ ██▒░ ██░░ ... ░▒██ ░▓███ ░███ - -Blended: (1-α)×source + α×new - -Extended Result (217 frames): -[████████████████████████████████████████████████████▓▓▒▒░░████████████████████████████████████████████████████████████████████] - └─ Smooth transition ─┘ -``` - ---- - -## Conclusion - -The Extension Module provides a powerful, flexible solution for iterative video generation with LTX-2. Key takeaways: - -1. **Always use 8n+1 conformance** (`ltx2_round_down`) when feeding samplers -2. **Start with overlap=25** and `linear_blend` for best results -3. **Enable quality features** (color match, seam search) only when needed -4. **Monitor the report output** to verify correct operation -5. **Test with simple configs first**, then optimize - -For support and updates, see the [IAMCCS-nodes repository](https://github.com/IAMCCS/IAMCCS-nodes). - ---- - -*Document Version: 1.0* -*Last Updated: January 2026* -*Extension Module Version: 87665e5* diff --git a/docs/LTX2_EXTENSION_NODES_GUIDE_EN.md b/docs/LTX2_EXTENSION_NODES_GUIDE_EN.md deleted file mode 100644 index 795ee80..0000000 --- a/docs/LTX2_EXTENSION_NODES_GUIDE_EN.md +++ /dev/null @@ -1,394 +0,0 @@ -# IAMCCS LTX-2 Extension Nodes — Final Guide (EN) - -This document explains how to use the IAMCCS LTX-2 nodes for **long-length / multi-segment video generation and extension** in ComfyUI, including the purpose of each widget and recommended usage patterns. - -## What problems these nodes solve - -1. **Seam artifacts between segments** (visible cut, flicker, exposure shift) -2. **Bad seam position** (the extension starts at an awkward frame) -3. **LTX VideoVAE frame-count constraint**: some encode paths require the number of guide frames to be of the form: - -$$N = 1 + 8k$$ - -4. **Workflow simplification**: reduce reliance on multiple helper nodes for overlap math, ranges, etc. - ---- - -## Quick decision guide (what to touch first) - -### If you get the LTX VideoVAE error: “Encode input must have 1 + 8 * x frames” - -This is the **`8n+1`** rule: the number of frames going into certain LTX/LTXV encode paths must be: - -$$N = 1 + 8k$$ - -In iterative extension workflows, this usually affects **the guide/start frames** you feed into the next segment. - -Use these fixes in this order: - -1) **Set `safe_mode = native_workflow_safe`** in `IAMCCS_LTX2_ExtensionModule` - - This extracts the start frames exactly like the original stable workflow: - - `start_images = extended_images[-overlap_frames:-1]` - -2) If you still need a strict `8n+1` count, set **`start_frames_rule`**: - - `ltx2_round_down`: most predictable and “never increases” the frame count. - - `ltx2_nearest`: useful if you want the closest valid count (may go up or down). - -3) If the frame rule is needed elsewhere (not on the extension module), use `IAMCCS_LTX2_FrameCountValidator` on the integer driving that node. - -### If the seam is visible (hard cut / flicker) - -- Start with: - - `overlap_mode = ease_in_out` (or `linear_blend` if you want the simplest behavior) - - keep overlap modest (common range: ~8–24 frames; larger overlap can help but costs compute/time) - -### If exposure/white balance shifts at the seam - -- Enable color matching: - - `color_match_mode = luma_only` (usually the safest) - - `color_match_strength = 0.3..0.7` - - `color_reference_window = 6..12` - -### If the seam “restarts weirdly” (bad timing / rewind) - -- Use seam search (try only after overlap/blend): - - `seam_search_mode = best_of_k` - - `k_search = 8..24` - -### If you are using AutoLink overlap loops - -- Prefer wiring `autolink_overlap_in` / `autolink_overlap_out` so each iteration can override overlap cleanly. - -### About `IAMCCS_LTX2_ExtensionModule_simple` - -- `IAMCCS_LTX2_ExtensionModule_simple` is the **minimal** variant of the Extension Module. -- It exposes only the core overlap/blend/math widgets (no color match, seam search, metrics). -- It does **not** expose `safe_mode` or `start_frames_rule` as widgets. -- It **always enforces** the LTX-2 start-frame rule $N = 1 + 8k$ automatically (round-down), to avoid VideoVAE encode frame-count errors. - -## Nodes overview - -- `IAMCCS_LTX2_ExtensionModule` - - Merges the previous segment (`source_images`) with the new segment (`new_images`) using overlap/blend. - - Outputs `extended_images` (merged batch) and `start_images` (frames used to guide the next segment). - - Optional seam improvements: exposure/color matching and best-of-k seam selection. - - Optional “native safe” extraction that matches the original stable workflow behavior. - - Optional AutoLink overlap loop I/O: - - `autolink_overlap_in` (override overlap when > 0) - - `autolink_overlap_out` (feed the next iteration) - -- `IAMCCS_LTX2_GetImageFromBatch` - - Extracts frames from the start/end of an image batch, or by an explicit range. - - Adds optional auto-count and diagnostics outputs. - - Optional “native safe” mode matching `images[-count:-1]` in from-end mode. - -- `IAMCCS_LTX2_ReferenceImageSwitch` - - Safe way to inject a **reference image** to improve identity/style consistency **without breaking overlap continuity**. - - Default is `none`, so existing workflows are unchanged. - -- `IAMCCS_LTX2_ReferenceStartFramesInjector` - - (New) Injects/blends the reference directly into the **guide/conditioning frames** (`start_images` / segment `images`). - - Useful when feeding the reference into `image_1` (empty latent image) has **weak or no identity effect**. - - Can be applied to **only one segment** (e.g. segment 3 only). - -- `IAMCCS_LTX2_FrameCountValidator` - - Helper to validate/correct an integer frame count to the `1 + 8*k` rule. - ---- - -## 1) IAMCCS_LTX2_ExtensionModule - -### Inputs - -**Required** - -- `source_images` (IMAGE) - - The current accumulated batch (previous segment output). -- `overlap_frames` (INT) - - How many frames overlap between segments. -- `overlap_side` (dropdown) - - `source`: overlap uses the tail of `source_images` against the head of `new_images`. - - `new_images`: swaps which side is treated as source/destination for blending. -- `overlap_mode` (dropdown) - - `cut`: hard cut (fastest, most visible seam). - - `linear_blend`: linear crossfade. - - `ease_in_out`: smoother crossfade. - - `filmic_crossfade`: gamma-aware blend (often smoother in highlights). - - `perceptual_crossfade`: LAB blend via Kornia (falls back if Kornia not installed). -- `enable_math` (BOOLEAN) - - Enables the built-in “how many start frames to output” calculation. -- `math_operation` (dropdown) - - Applies to `overlap_frames` (as `a`) and `math_value_b` (as `b`) when computing how many frames to output as `start_images`. - - Typical: `a-b` or `a-1`. - -**Safety / LTX rule** - -- `safe_mode` (dropdown) - - `none`: uses the node’s normal start-images logic. - - `native_workflow_safe`: extracts start images exactly like the proven stable graph: - - `start_images = extended_images[-overlap_frames:-1]` - - Use this if you are hitting the LTX VideoVAE error “Encode input must have 1 + 8 * x frames”. - -- `start_frames_rule` (dropdown) - - `none`: do not modify the calculated number of start frames. - - `ltx2_round_down`: force the count down to the nearest valid `1 + 8*k`. - - `ltx2_nearest`: choose the nearest valid `1 + 8*k` within bounds. - - Use this when a downstream node (VideoVAE encode/guide) requires `1 + 8*k` frame counts. - -**Quality upgrades (defaults are safe/off)** - -- `color_match_mode` (dropdown) - - `none`: no change (original behavior). - - `luma_only`: match exposure/contrast on luma. - - `per_channel`: match mean/std per RGB channel. -- `color_match_strength` (FLOAT 0..1) - - Blend between original and matched. -- `color_reference_window` (INT) - - Number of frames used from tail/head for statistics. - -- `seam_search_mode` (dropdown) - - `none`: no seam search. - - `best_of_k`: search for a better seam by testing candidate offsets. -- `k_search` (INT) - - How many candidate offsets to test (0 disables). -- `metric_weight_color` (FLOAT) - - Weight of luma continuity in the seam score. -- `metric_weight_edges` (FLOAT) - - Weight of edge continuity in the seam score. - -**Optional** - -- `new_images` (IMAGE) - - The newly generated segment. - - If omitted, the node can be used as a “prep” node (it will still output `start_images` from the current batch). -- `math_value_b` (INT) - - Used by `math_operation`. - -### Outputs - -- `source_images` (IMAGE) — passthrough -- `start_images` (IMAGE) — frames to feed as guide for the next segment -- `extended_images` (IMAGE) — merged batch -- `overlap_frames` (INT) -- `calculated_frames` (INT) — actual number of frames output in `start_images` -- `extension_frames` (INT) — how many frames were added -- `report` (STRING) - -### Recommended settings - -- Most stable: `safe_mode = native_workflow_safe`, `overlap_mode = ease_in_out` (or `linear_blend`) -- If you see exposure shift: `color_match_mode = luma_only`, `strength = 0.3..0.7` -- If you see weird seam timing: `seam_search_mode = best_of_k`, `k_search = 8..24` - ---- - -## 2) IAMCCS_LTX2_GetImageFromBatch - -### Purpose -A small helper to extract frames for the next segment or for debugging. - -### Inputs - -- `images` (IMAGE) -- `mode` (dropdown) - - `from_start`: take the first `count` frames - - `from_end`: take the last `count` frames - - `range`: take `[start_index:end_index)` -- `count` (INT) - -**Upgrades** - -- `auto_count_mode` (dropdown) - - `none`: use `count` widget. - - `prefer_input`: use `count_in` if connected. - - `use_widget`: explicitly use the widget value. -- `diagnostics` (dropdown) - - `none`: normal behavior. - - `basic`: exposes `start_index` and `end_index` outputs. - -**Safety / LTX rule** - -- `count_rule` (dropdown) - - `none` / `ltx2_round_down` / `ltx2_nearest` for `1 + 8*k`. -- `safe_mode` (dropdown) - - `none`: normal extraction. - - `native_workflow_safe`: for `from_end` uses `images[-count:-1]`. - -**Optional** - -- `count_in` (INT) -- `start_index` / `end_index` (INT) for `range` mode. - -### Outputs - -- `images` (IMAGE) -- `count` (INT) -- `report` (STRING) -- `start_index`, `end_index` (INT) - ---- - -## 3) IAMCCS_LTX2_ReferenceImageSwitch - -### Why this node exists -In long-length generation, you typically want: -- **Continuity** driven by overlap/start frames -- **Identity/style consistency** reinforced by a stable reference image - -This node lets you add a reference image **without replacing** the overlap continuity input. - -### Inputs - -- `default_image` (IMAGE) - - What the workflow already used before (pass-through by default). -- `mode` (dropdown) - - `none`: output `default_image` (fully backward-compatible). - - `use_reference`: output `reference_image`. - - `blend`: output mix of `default_image` and `reference_image`. -- `blend_strength` (FLOAT) - - Only for `blend` mode. -- `reference_image` (optional IMAGE) - - If not connected, the node behaves like `none`. - -### Output - -- `image` (IMAGE) -- `report` (STRING) - -### Practical usage - -- Insert it on the **auxiliary** image input of your segment sampler (often called `image_1`). -- Keep overlap/start frames connected exactly as before. -- If you enable `use_reference`/`blend`, the reference is **automatically resized** to match `default_image` (more stable for downstream nodes). - -Note: in many LTX/LTXV workflows, feeding the reference into `image_1` (empty latent image) may not be enough to “lock” identity when a face is revealed later in the segment. In that case, use the node below. - ---- - -## 3b) IAMCCS_LTX2_ReferenceStartFramesInjector - -### Why it exists -If identity drifts even with a reference, it often means the reference is connected to an input that the model barely uses. This node modifies the actual guide/conditioning frames. - -### Inputs - -- `start_images` (IMAGE) - - The guide frames that feed the segment (typically `start_images` from the extension module, or the sampler’s `images` input). -- `mode` - - `none`: passthrough. - - `inject`: replaces the selected frames with the reference. - - `blend`: mixes reference and original frames. -- `blend_strength` (0..1) - - Only used for `blend` (0 = no effect, 1 = full reference). In `inject` it behaves like 1. -- `frames_to_inject` (INT) - - How many guide frames to modify. -- `ramp` (BOOLEAN) - - If `true`, applies a gradual ramp across the injected frames. -- `position` - - `tail`: last K frames (usually best, closest to the seam). - - `head`: first K frames. -- `reference_image` (optional IMAGE) - - Usually the output of `IAMCCS_LTX2_ReferenceImageSwitch`. - -### Outputs - -- `start_images` (IMAGE) -- `report` (STRING) - -### Recommended starter settings - -- If identity is not sticking but you want to preserve continuity: - - `mode = blend` - - `frames_to_inject = 3..6` - - `blend_strength = 0.5..0.85` - - `ramp = true` - - `position = tail` - -If you see seam discontinuity, lower `blend_strength` and/or reduce `frames_to_inject`. - ---- - -## How to decide when/where to use a reference - -Quick checklist: - -1. **Is the face/identity visible in the first frames of the segment?** - - Yes → a reference can work well. - - No (reveal happens mid/late segment) → the reference may have little leverage: consider cutting segments so the reveal starts at the segment boundary, or use `ReferenceStartFramesInjector` (and/or dedicated tools like FaceID/IPAdapter if compatible). - -2. **What are you stabilizing?** - - Style / global look → `ReferenceImageSwitch` (or `color_match_mode` in ExtensionModule) is often enough. - - Identity (specific face) → `ReferenceStartFramesInjector` is more likely required. - -3. **Where to wire it?** - - `image_1` / empty latent image: can be a hint, not guaranteed. - - `images` / start frames (conditioning): highest impact. - -4. **How to limit it to one segment (e.g. segment 3 only)** - - Place `ReferenceStartFramesInjector` only in the path feeding that segment’s `images` / `start_images`. - - Leave other segments untouched (no injector). - -## 4) IAMCCS_LTX2_FrameCountValidator - -### Inputs - -- `frame_count` (INT) -- `auto_correct` (BOOLEAN) -- `correction_mode` (`nearest` / `round_up` / `round_down`) - -### Outputs - -- `validated_count` (INT) -- `is_valid` (BOOLEAN) -- `nearest_valid` (INT) -- `report` (STRING) - ---- - -## Common workflows / use cases - -### A) Long-length extension (multi segment) -1. Generate segment 1. -2. Use `IAMCCS_LTX2_ExtensionModule` to compute `start_images` and merge segments. -3. Feed `start_images` into the next segment guide/conditioning. -4. Repeat. - -Recommended: enable `safe_mode = native_workflow_safe` if you see LTX frame-count errors. - -### B) Reduce seams -- Prefer `ease_in_out` or `filmic_crossfade`. -- Use `color_match_mode` if you see exposure shifts. -- Use `best_of_k` seam search if the seam starts at a bad moment. - -### C) Improve identity consistency -- Add `IAMCCS_LTX2_ReferenceImageSwitch` to `image_1`. -- Connect a single reference image and set mode to `blend` (start at 0.2..0.4). - ---- - -## Troubleshooting - -- **“IAMCCS_LTX2_ReferenceImageSwitch not found”** - - Ensure you updated the IAMCCS nodes and restart ComfyUI. - - The node must be exported in the package registry (`__init__.py`). - -- **“Encode input must have 1 + 8 * x frames”** - - Use `safe_mode = native_workflow_safe` or set `start_frames_rule/count_rule` to enforce `1 + 8*k`. - -- **Border motion artifacts (edge warping / flicker)** - - Note: `metric_weight_edges` and `best_of_k` improve seam selection *inside the overlap* between segments; they do not automatically “fix” frame borders. - - Common improvements: - - Avoid changing resize/crop between segments; keep one resolution end-to-end. - - Prefer “clean” resolutions (multiples of 64 where possible) to reduce VAE boundary artifacts. - - Quick workaround: apply a small crop (e.g., 8–16 px per side) then resize back. - - Helpful nodes (IAMCCS): - - `IAMCCS_LTX2_ImageBatchPadReflect`: adds a reflect border (increases resolution). - - `IAMCCS_LTX2_ImageBatchCropByPad`: removes that border (back to target resolution). - - Recommended usage (when you want the model to have more border context): - - Pick `pad_x/pad_y` (e.g., 16). - - Generate at a higher resolution: `W_pad = W + 2*pad_x`, `H_pad = H + 2*pad_y` (including `EmptyImage`). - - If you have “initial”/reference images at the old resolution, run them through `PadReflect` to reach `W_pad x H_pad`. - - At the end (before `CreateVideo`), run `CropByPad` with the same `pad_x/pad_y` to return to `W x H`. - -- **Reference image causes a resolution error** - - Resize/crop the reference to match your workflow resolution before feeding it. diff --git a/docs/WanImageMotion.md b/docs/WanImageMotion.md deleted file mode 100644 index 0426597..0000000 --- a/docs/WanImageMotion.md +++ /dev/null @@ -1,136 +0,0 @@ -# IAMCCS WanImageMotion - -`IAMCCS_WanImageMotion` is a **drop-in replacement** for the SVIPro latent-conditioning node used in WAN image-to-video workflows. Its purpose is to build the *conditioning* fields required by the WAN I2V pipeline while optionally boosting perceived motion via a controllable `motion` parameter. - -This node **does not perform sampling**. It only: -- prepares an “empty” latent sequence to be denoised by the sampler, and -- injects `concat_latent_image` and `concat_mask` into both positive/negative conditioning. - ---- - -## Inputs - -Required: -- `positive` / `negative` (`CONDITIONING`): conditioning streams to be augmented. -- `length` (`INT`): number of frames in the video. Internally converted to latent-frame count: - $T = \left\lfloor\frac{length-1}{4}\right\rfloor + 1$. -- `anchor_samples` (`LATENT`): the “anchor” latent(s), typically representing the initial visual content. -- `motion_latent_count` (`INT`): how many latent frames to take from `prev_samples` (if present) to seed motion. -- `motion` (`FLOAT`): motion amplification factor. `1.0` means “no change”. Values > `1.0` increase motion. -- `motion_mode` (dropdown): chooses *where* the motion boost is applied. -- `latent_precision` (dropdown): controls the dtype used for the **empty latent** allocation (quality vs VRAM). - - `auto`: matches anchor samples dtype - - `fp16`: half precision (lower VRAM, slight quality loss) - - `fp32`: full precision (higher VRAM, maximum quality) -- `vram_profile` (dropdown): chooses *how* the motion boost is computed to reduce peak VRAM. - - `normal`: process all frames at once (fastest, highest VRAM) - - `chunked_blocks_2` / `chunked_blocks_4`: process in chunks (balanced) - - `loop_per_frame (lowest_vram)`: process one frame at a time - - `cpu_offload (slowest)`: offload computation to CPU (extreme low VRAM) -- `include_padding_in_motion` (`BOOLEAN`): if enabled, the motion boost may also affect padded latent frames. - - **Critical for single-frame anchors**: when `anchor_samples` has only `T=1` and there are no `prev_samples`, this must be `True` to apply any motion boost. - - The node will log a warning if motion_range is empty and suggest enabling this option. - -Optional: -- `prev_samples` (`LATENT`): previous latent sequence; when provided, the last `motion_latent_count` latent frames are appended after the anchor to seed motion. - ---- - -## Outputs - -- `positive` / `negative` (`CONDITIONING`): same as input, but with added conditioning keys: - - `concat_latent_image` - - `concat_mask` -- `latent` (`LATENT`): an **empty latent sequence** shaped like the target video latents. This is what the sampler will denoise. - ---- - -## Core Logic - -### 1) Create the empty latent sequence -The node allocates an empty latent tensor with shape: -- `[B, 16, T, H, W]` where `T` is derived from `length`. - -This tensor is intentionally initialized to zeros. - -`latent_precision` affects only this allocation: -- `auto`: matches the dtype of `anchor_samples` (recommended). -- `fp16`: forces FP16 (lower VRAM, can be slightly less stable). -- `fp32`: forces FP32 (higher VRAM, can be slightly more stable). - -### 2) Build `concat_latent_image` -The node builds a latent conditioning sequence (`image_cond_latent`) by concatenating: -1. `anchor_samples["samples"]` (anchor latents) -2. the last `motion_latent_count` frames from `prev_samples["samples"]` (only if provided) -3. zero padding to reach exactly `T` latent frames - -Padding is processed with `Wan21().process_out(...)` to match expected latent formatting. - -### 3) Build `concat_mask` -A mask is created with shape `[1, 1, T, H, W]`. -- The first latent frame is unmasked: `mask[:, :, :1] = 0.0` -- All subsequent latent frames are masked: `1.0` - -### 4) Inject into conditioning -The node injects: -- `concat_latent_image = image_cond_latent` -- `concat_mask = mask` - -into **both** `positive` and `negative` conditioning. - ---- - -## Motion Boost (`motion`) - -When `motion > 1.0`, the node amplifies motion by modifying selected latent frames while preserving the per-frame mean offset to reduce brightness/shift artifacts. - -Let: -- `base` be the first latent frame `image_cond_latent[:, :, 0:1]` -- `x` be the target latent frames to be modified - -The transformation is: -1. `diff = x - base` -2. `mean = mean(diff over C,H,W)` (per-batch/per-time) -3. `diff_centered = diff - mean` -4. `scaled = base + diff_centered * motion + mean` -5. clamp to a safe range: `[-6, 6]` - -By default, the node **does not modify padding frames**. - -If `include_padding_in_motion = true`, the node may treat padded frames as motion targets. This can help when `anchor_samples` provides only a single latent frame (e.g. `T=1`) and there are no motion latents from `prev_samples`. - ---- - -## Motion Mode (two modes) - -### `motion_only (prev_samples)` -- Applies the motion boost **only** to the latent frames coming from `prev_samples`. -- Conservative: changes less of the anchor content. -- Recommended when you want motion injection without destabilizing the initial anchor. - -### `all_nonfirst (anchor+motion)` -- Applies the motion boost to **all real latent frames except the first** (anchor + motion latents). -- More aggressive: stronger motion effect, but can change the look more. - ---- - -## VRAM Profile - -These profiles only change *how the motion boost is computed* (peak memory vs speed). They do not change the rest of the pipeline. - -- `normal`: processes the selected time range in one tensor block (fastest, highest peak VRAM). -- `chunked_blocks_2`: processes 2 latent frames at a time (lower peak VRAM). -- `chunked_blocks_4`: processes 4 latent frames at a time (middle ground). -- `loop_per_frame (lowest_vram)`: processes 1 latent frame at a time (lowest peak VRAM, slower). -- `cpu_offload (slowest)`: moves the targeted slice to CPU for the computation, then copies back (lowest GPU peak, highest runtime cost). - ---- - -## Notes / Troubleshooting - -- If you are hitting CUDA OOM at high resolutions, try: - 1) `vram_profile = chunked_blocks_2` - 2) then `loop_per_frame (lowest_vram)` - 3) then (only if necessary) `cpu_offload (slowest)` - -- If you want to isolate whether OOM is caused by motion scaling vs sampling, set `motion = 1.0` temporarily. diff --git a/iamccs_ltx2_extension_module.py b/iamccs_ltx2_extension_module.py index e3fabf1..cc7a218 100644 --- a/iamccs_ltx2_extension_module.py +++ b/iamccs_ltx2_extension_module.py @@ -1141,6 +1141,181 @@ class IAMCCS_LTX2_ExtensionModule_simple(IAMCCS_LTX2_ExtensionModule): ) +class IAMCCS_LTX2_FirstLastFramesController: + """ + First-Last Frame (FLF) controller for LTX-2 image conditioning. + + Injects a reference first_frame and/or last_frame directly into the + `images` conditioning tensor used by the sampler. Works on the + 'MISTO' pattern: the tensor already contains both external images and + generated frames — this node simply overwrites / blends the head and/or + tail K frames with the supplied references. + + Modes + ----- + hard_lock : replace the K frames completely with the reference + linear_blend: weighted blend (reference * strength + original * (1-strength)) + ramp : progressive blend, strength ramps from 0 → strength over K frames + (for head: 0→strength left-to-right; for tail: strength→0 left-to-right) + + Positions + --------- + head : operate on first K frames only + tail : operate on last K frames only + both : operate on both ends simultaneously + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE", { + "tooltip": "Conditioning image batch (the 'images' input to the sampler)" + }), + "k_frames": ("INT", { + "default": 4, + "min": 1, + "max": 64, + "step": 1, + "tooltip": "Number of frames to affect at each injection site" + }), + "mode": (["hard_lock", "linear_blend", "ramp"], { + "default": "hard_lock", + "tooltip": ( + "hard_lock: full replace | " + "linear_blend: uniform blend at given strength | " + "ramp: progressive blend from 0 to strength" + ), + }), + "position": (["head", "tail", "both"], { + "default": "both", + "tooltip": "Where to inject references (head=first K, tail=last K, both=head+tail)", + }), + "blend_strength": ("FLOAT", { + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "tooltip": "Max blend weight (ignored for hard_lock which always uses 1.0)" + }), + }, + "optional": { + "first_frame": ("IMAGE", { + "tooltip": "Reference image to inject at the HEAD of the batch (ignored if position=tail)" + }), + "last_frame": ("IMAGE", { + "tooltip": "Reference image to inject at the TAIL of the batch (ignored if position=head)" + }), + }, + } + + RETURN_TYPES = ("IMAGE", "STRING") + RETURN_NAMES = ("images", "report") + FUNCTION = "apply" + CATEGORY = "IAMCCS/LTX-2" + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + @staticmethod + def _resize_to(image: torch.Tensor, target_h: int, target_w: int) -> torch.Tensor: + """Resize image tensor [N,H,W,C] to (target_h, target_w).""" + if int(image.shape[1]) == target_h and int(image.shape[2]) == target_w: + return image + x = image.permute(0, 3, 1, 2) + x = F.interpolate(x.float(), size=(target_h, target_w), mode="bilinear", align_corners=False) + return x.permute(0, 2, 3, 1).clamp(0.0, 1.0).to(image.dtype) + + @staticmethod + def _broadcast_ref(ref: torch.Tensor, k: int) -> torch.Tensor: + """Ensure ref has exactly k frames (repeat single-frame or crop).""" + n = int(ref.shape[0]) + if n == k: + return ref + if n == 1: + return ref.repeat(k, 1, 1, 1) + return ref[:k] + + @staticmethod + def _blend_weights(k: int, mode: str, max_s: float, ramp_direction: str) -> list: + """ + Returns list of k blend weights. + ramp_direction: 'up' = 0→max_s, 'down' = max_s→0 + """ + if mode == "hard_lock": + return [1.0] * k + if mode == "linear_blend": + return [max_s] * k + # ramp + if k == 1: + return [max_s] + if ramp_direction == "up": + return [max_s * float(i + 1) / float(k) for i in range(k)] + else: # down + return [max_s * float(k - i) / float(k) for i in range(k)] + + def _inject( + self, + out: torch.Tensor, + ref: torch.Tensor, + idxs: list, + weights: list, + ) -> torch.Tensor: + """Blend ref frames into out at given indices with given per-frame weights.""" + h, w = int(out.shape[1]), int(out.shape[2]) + ref_r = self._resize_to(ref, h, w) + ref_r = self._broadcast_ref(ref_r, len(idxs)) + for j, i in enumerate(idxs): + s = float(weights[j]) + out[i] = ((1.0 - s) * out[i].float() + s * ref_r[j].float()).clamp(0.0, 1.0).to(out.dtype) + return out + + # ------------------------------------------------------------------ + # main + # ------------------------------------------------------------------ + def apply( + self, + images: torch.Tensor, + k_frames: int, + mode: str, + position: str, + blend_strength: float, + first_frame: Optional[torch.Tensor] = None, + last_frame: Optional[torch.Tensor] = None, + ): + total = int(images.shape[0]) + k = max(1, min(int(k_frames), total // 2 if total > 1 else 1)) + max_s = 1.0 if mode == "hard_lock" else float(max(0.0, min(1.0, blend_strength))) + + out = images.clone() + ops = [] + + do_head = position in ("head", "both") + do_tail = position in ("tail", "both") + + if do_head and first_frame is not None: + idxs = list(range(0, k)) + # ramp up: 0 → max_s (anchor gets full weight at the end) + weights = self._blend_weights(k, mode, max_s, "up") + out = self._inject(out, first_frame, idxs, weights) + ops.append(f"head(k={k},mode={mode},s={max_s:.2f})") + + if do_tail and last_frame is not None: + idxs = list(range(total - k, total)) + # ramp down: max_s → 0 (anchor gets full weight at the start) + weights = self._blend_weights(k, mode, max_s, "down") + out = self._inject(out, last_frame, idxs, weights) + ops.append(f"tail(k={k},mode={mode},s={max_s:.2f})") + + if not ops: + report = f"FLF Controller: no-op (position={position}, first_frame={'yes' if first_frame is not None else 'no'}, last_frame={'yes' if last_frame is not None else 'no'})" + else: + report = "FLF Controller: " + " + ".join(ops) + f" | total_frames={total}" + + _log.debug(report) + return (out, report) + + # Node registration NODE_CLASS_MAPPINGS = { "IAMCCS_LTX2_ExtensionModule": IAMCCS_LTX2_ExtensionModule, @@ -1149,6 +1324,7 @@ NODE_CLASS_MAPPINGS = { "IAMCCS_LTX2_ReferenceImageSwitch": IAMCCS_LTX2_ReferenceImageSwitch, "IAMCCS_LTX2_ReferenceStartFramesInjector": IAMCCS_LTX2_ReferenceStartFramesInjector, "IAMCCS_LTX2_FrameCountValidator": IAMCCS_LTX2_FrameCountValidator, + "IAMCCS_LTX2_FirstLastFramesController": IAMCCS_LTX2_FirstLastFramesController, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -1158,4 +1334,5 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_LTX2_ReferenceImageSwitch": "LTX-2 Reference Image Switch 🧷", "IAMCCS_LTX2_ReferenceStartFramesInjector": "LTX-2 Inject Reference Into Start Frames 🧬", "IAMCCS_LTX2_FrameCountValidator": "LTX-2 Frame Count Validator ✅ (8n+1)", + "IAMCCS_LTX2_FirstLastFramesController": "LTX-2 First-Last Frames Controller 🎯", } diff --git a/iamccs_qwen_vl_flf.py b/iamccs_qwen_vl_flf.py new file mode 100644 index 0000000..8d80fc0 --- /dev/null +++ b/iamccs_qwen_vl_flf.py @@ -0,0 +1,524 @@ +# ========================================================== +# iamccs_qwen_vl_flf.py — IAMCCS QwenVL First/Last Frame +# ========================================================== +# Dual-image QwenVL node: accepts a FIRST FRAME and a LAST FRAME, +# then queries QwenVL to describe the motion/action occurring +# between the two frames — the ideal prompt for FLF video generators +# (WAN SVI Pro, LTX-2 FLF, etc.). +# +# This is a 1:1 extension of AILab_QwenVL (ComfyUI-QwenVL) +# with the image input replaced by two independent IMAGE inputs. +# +# Author : IAMCCS (carminecristalloscalzi.com / patreon.com/IAMCCS) +# License: GPL-3.0 +# ========================================================== + +import importlib +import os +import sys +from pathlib import Path + +import numpy as np +import torch + + +# --------------------------------------------------------------------------- +# Dynamic import of QwenVLBase from the ComfyUI-QwenVL custom node +# --------------------------------------------------------------------------- + +def _import_qwen_base(): + """Locate and import QwenVLBase from ComfyUI-QwenVL, however it was loaded.""" + + # 1) Already loaded by ComfyUI's module system? + for module_name, module in sys.modules.items(): + if "AILab_QwenVL" in module_name: + if hasattr(module, "QwenVLBase"): + return module.QwenVLBase + + # 2) Look at sibling custom_node directories + this_dir = Path(__file__).resolve().parent # …/IAMCCS-nodes + custom_nodes_dir = this_dir.parent # …/custom_nodes + + candidates = [ + custom_nodes_dir / "ComfyUI-QwenVL" / "AILab_QwenVL.py", + custom_nodes_dir / "comfyui-qwenvl" / "AILab_QwenVL.py", + ] + for candidate in candidates: + if candidate.exists(): + spec = importlib.util.spec_from_file_location("AILab_QwenVL_ext", str(candidate)) + mod = importlib.util.module_from_spec(spec) + sys.modules["AILab_QwenVL_ext"] = mod + spec.loader.exec_module(mod) + return mod.QwenVLBase + + raise ImportError( + "[IAMCCS_QWEN_VL_FLF] Cannot find QwenVLBase. " + "Make sure ComfyUI-QwenVL is installed under custom_nodes/ComfyUI-QwenVL." + ) + + +# Lazy-load so the import error is surfaced only when the node is used +_QwenVLBase = None + +def _get_base(): + global _QwenVLBase + if _QwenVLBase is None: + _QwenVLBase = _import_qwen_base() + return _QwenVLBase + + +# --------------------------------------------------------------------------- +# FLF-specific prompt presets +# --------------------------------------------------------------------------- + +FLF_PRESET_PROMPTS = [ + "🎬 Video Action Description (FLF)", + "🎥 Cinematic Motion Prompt (FLF)", + "🏃 Subject Movement & Camera (FLF)", + "🌀 Scene Transition Description (FLF)", + "📷 Static Shot Action Prompt (FLF)", + "🌊 WAN 2.2 SVI Pro 2 — FLF Prompt", + "⚡ LTX-2 FLF Prompt", +] + +FLF_SYSTEM_PROMPTS = { + "🎬 Video Action Description (FLF)": ( + "You are given two images: the FIRST FRAME and the LAST FRAME of a video clip. " + "Your task is to write a single, concise video-generation prompt (2-4 sentences) that describes " + "the motion, action, and visual transformation occurring between these two frames. " + "Include: subject actions, camera movement (pan, tilt, zoom, static, etc.), environmental changes, " + "lighting shifts, and any notable visual effects. " + "Write in present tense, imperative style, as if directing an AI video generator. " + "Do NOT describe what is in the images statically — focus entirely on the MOTION and TRANSITION." + ), + "🎥 Cinematic Motion Prompt (FLF)": ( + "You are given the FIRST FRAME and the LAST FRAME of a cinematic video shot. " + "Describe the complete camera movement and subject action as a professional cinematography prompt. " + "Include: shot type (close-up, wide, medium), camera movement (dolly, pan, handheld shake, etc.), " + "subject movement direction and speed, focus changes, and mood/lighting evolution. " + "Output a single fluid paragraph suitable for an AI video generator." + ), + "🏃 Subject Movement & Camera (FLF)": ( + "Compare the first frame and the last frame provided. " + "Write a detailed motion description focused on: " + "1) How the main subject(s) move between the two frames (direction, speed, posture changes), " + "2) Camera behavior (static, following, pulling back, zooming in/out), " + "3) Background/environment changes. " + "Summarize in 2-3 sentences optimized for AI video generation input." + ), + "🌀 Scene Transition Description (FLF)": ( + "You are shown the opening frame and the closing frame of a video sequence. " + "Analyse the differences and infer what visual narrative connects them. " + "Write a prompt that describes the scene transition: object positions, lighting evolution, " + "atmospheric changes, and any implied motion. Be specific and concise (2-3 sentences). " + "The output should work as a direct input for an AI video generator." + ), + "📷 Static Shot Action Prompt (FLF)": ( + "Given the first and last frame of a static-camera video clip, " + "describe only the subject's actions and movements within the fixed frame. " + "Mention entry/exit directions, gestures, expressions, interaction with objects, " + "and any notable background activity. " + "Output a crisp 1-3 sentence prompt for an AI video generator." + ), + + "🌊 WAN 2.2 SVI Pro 2 — FLF Prompt": ( + "You are an AI video prompt expert for the WAN 2.2 SVI Pro 2 model in ComfyUI. " + "I will give you two images: the FIRST FRAME and the LAST FRAME of a video clip. " + "Your job is to write one single, detailed prompt in clear English " + "that describes the motion and transformation occurring between these two frames, " + "suitable for use directly with WAN 2.2 SVI Pro 2. " + "Rules: " + "Write normal sentences, not JSON, not a list. " + "Include: subject action and movement, camera motion (pan, tilt, zoom, dolly, static), " + "environment and background evolution, lighting and atmosphere changes, " + "and overall motion style (slow, fast, smooth, handheld). " + "Focus entirely on the MOTION and TRANSITION between the two frames — " + "do NOT describe the frames as static images. " + "Keep it under 4 sentences. " + "Do not mention these rules in your answer." + ), + + "⚡ LTX-2 FLF Prompt": ( + "You are an AI video prompt expert for the LTX-2 First/Last Frame (FLF) model in ComfyUI. " + "I will give you two images: the FIRST FRAME and the LAST FRAME of a video clip. " + "Your job is to write one single, detailed prompt in clear English " + "that describes the visual and motion continuity connecting these two frames, " + "optimised for LTX-2 FLF video generation. " + "Rules: " + "Write normal sentences, not JSON, not a list. " + "Include: subject description and action, precise camera movement, " + "spatial transitions (near-to-far, left-to-right, etc.), " + "lighting and color mood evolution between frames, " + "and motion speed/smoothness (e.g. slow drift, rapid motion, gradual zoom). " + "LTX-2 responds best to prompts that are visually rich and temporally explicit — " + "describe what changes and how it changes, not just what is visible. " + "Keep it under 4 sentences. " + "Do not mention these rules in your answer." + ), +} + +FLF_TOOLTIPS = { + "first_frame": "The opening frame of the video clip (frame 0).", + "last_frame": "The closing frame of the video clip (last frame).", + "preset_prompt": "Built-in FLF instruction set for QwenVL. Each preset focuses on a different aspect of motion description.", + "custom_prompt": "Optional override — replaces the preset completely when filled in.", + "model_name": "Pick the Qwen-VL checkpoint. First run downloads weights into models/LLM/Qwen-VL.", + "quantization": "Precision vs VRAM. FP16 = best quality; 8-bit = 8-16 GB GPUs; 4-bit = 6 GB or lower.", + "attention_mode": "auto tries SageAttention / Flash-Attn v2 and falls back to SDPA.", + "max_tokens": "Maximum tokens to generate. 256-512 is usually sufficient for motion prompts.", + "keep_model_loaded": "Keep model in VRAM after generation to skip reloading on next run.", + "seed": "Random seed — reuse to reproduce the same description.", + "use_torch_compile": "Enable torch.compile (reduce-overhead) on supported CUDA/Torch 2.1+ builds.", + "device": "Inference device: auto, cpu, mps, or cuda:N.", + "temperature": "Sampling randomness (when num_beams=1). 0.2-0.4 focused, 0.7+ creative.", + "top_p": "Nucleus sampling cutoff (when num_beams=1).", + "num_beams": "Beam-search width. >1 disables temperature/top_p for more stable output.", + "repetition_penalty": "Values >1 penalise repeated phrases (1.1-1.3 recommended).", +} + + +# --------------------------------------------------------------------------- +# FLF mixin — overrides generate() to accept two frames +# --------------------------------------------------------------------------- + +class _FLFMixin: + """Mixin that provides dual-image (first/last frame) generation.""" + + @staticmethod + def tensor_to_pil(tensor): + if tensor is None: + return None + if tensor.dim() == 4: + tensor = tensor[0] + array = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8) + from PIL import Image + return Image.fromarray(array) + + @torch.no_grad() + def generate_flf( + self, + prompt_text, + first_frame, + last_frame, + max_tokens, + temperature, + top_p, + num_beams, + repetition_penalty, + ): + """Build a two-image conversation: [first_frame, last_frame, text prompt].""" + content = [] + + img1 = self.tensor_to_pil(first_frame) + img2 = self.tensor_to_pil(last_frame) + + if img1 is not None: + content.append({"type": "image", "image": img1}) + if img2 is not None: + content.append({"type": "image", "image": img2}) + + content.append({"type": "text", "text": prompt_text}) + + conversation = [{"role": "user", "content": content}] + + chat = self.processor.apply_chat_template( + conversation, tokenize=False, add_generation_prompt=True + ) + images = [item["image"] for item in content if item["type"] == "image"] + processed = self.processor( + text=chat, + images=images or None, + videos=None, + return_tensors="pt", + ) + + model_device = next(self.model.parameters()).device + model_inputs = { + k: v.to(model_device) if torch.is_tensor(v) else v + for k, v in processed.items() + } + + stop_tokens = [self.tokenizer.eos_token_id] + if hasattr(self.tokenizer, "eot_id") and self.tokenizer.eot_id is not None: + stop_tokens.append(self.tokenizer.eot_id) + + kwargs = { + "max_new_tokens": max_tokens, + "repetition_penalty": repetition_penalty, + "num_beams": num_beams, + "eos_token_id": stop_tokens, + "pad_token_id": self.tokenizer.pad_token_id, + } + if num_beams == 1: + kwargs.update({"do_sample": True, "temperature": temperature, "top_p": top_p}) + else: + kwargs["do_sample"] = False + + outputs = self.model.generate(**model_inputs, **kwargs) + if torch.cuda.is_available(): + torch.cuda.synchronize() + + input_len = model_inputs["input_ids"].shape[-1] + text = self.tokenizer.decode(outputs[0, input_len:], skip_special_tokens=True) + return text.strip() + + def run_flf( + self, + model_name, + quantization, + preset_prompt, + custom_prompt, + first_frame, + last_frame, + max_tokens, + temperature, + top_p, + num_beams, + repetition_penalty, + seed, + keep_model_loaded, + attention_mode, + use_torch_compile, + device, + ): + from comfy.utils import ProgressBar + pbar = ProgressBar(3) + + torch.manual_seed(seed) + prompt = FLF_SYSTEM_PROMPTS.get(preset_prompt, preset_prompt) + if custom_prompt and custom_prompt.strip(): + prompt = custom_prompt.strip() + + pbar.update_absolute(1, 3, None) + + self.load_model( + model_name, + quantization, + attention_mode, + use_torch_compile, + device, + keep_model_loaded, + ) + + pbar.update_absolute(2, 3, None) + + try: + text = self.generate_flf( + prompt, + first_frame, + last_frame, + max_tokens, + temperature, + top_p, + num_beams, + repetition_penalty, + ) + pbar.update_absolute(3, 3, None) + return (text,) + finally: + if not keep_model_loaded: + self.clear() + + +# --------------------------------------------------------------------------- +# Node class factory (deferred because QwenVLBase is lazy-loaded) +# --------------------------------------------------------------------------- + +def _build_node_classes(): + """Return (IAMCCS_QWEN_VL_FLF, IAMCCS_QWEN_VL_FLF_Advanced) after QwenVLBase loads.""" + Base = _get_base() + + # Import Quantization enum from the same module as Base + import sys + qwen_mod = sys.modules.get("AILab_QwenVL") or sys.modules.get("AILab_QwenVL_ext") + if qwen_mod is None: + # The module might be registered under a different key + for k, v in sys.modules.items(): + if "AILab_QwenVL" in k and hasattr(v, "Quantization"): + qwen_mod = v + break + if qwen_mod is None: + raise ImportError("[IAMCCS_QWEN_VL_FLF] Could not locate Quantization enum in QwenVL module.") + + Quantization = qwen_mod.Quantization + ATTENTION_MODES = qwen_mod.ATTENTION_MODES + HF_VL_MODELS = qwen_mod.HF_VL_MODELS + + # ------------------------------------------------------------------ + # Simple version + # ------------------------------------------------------------------ + class IAMCCS_QWEN_VL_FLF(_FLFMixin, Base): + """QwenVL node with FIRST FRAME + LAST FRAME inputs for FLF video generation.""" + + @classmethod + def INPUT_TYPES(cls): + # Refresh model list at call time (models may be downloaded after startup) + models = list(HF_VL_MODELS.keys()) + default_model = models[0] if models else "Qwen2.5-VL-3B-Instruct" + default_prompt = ( + "🎬 Video Action Description (FLF)" + if "🎬 Video Action Description (FLF)" in FLF_PRESET_PROMPTS + else FLF_PRESET_PROMPTS[0] + ) + return { + "required": { + "model_name": (models, {"default": default_model, "tooltip": FLF_TOOLTIPS["model_name"]}), + "quantization": (Quantization.get_values(), {"default": Quantization.FP16.value, "tooltip": FLF_TOOLTIPS["quantization"]}), + "attention_mode": (ATTENTION_MODES, {"default": "auto", "tooltip": FLF_TOOLTIPS["attention_mode"]}), + "preset_prompt": (FLF_PRESET_PROMPTS, {"default": default_prompt, "tooltip": FLF_TOOLTIPS["preset_prompt"]}), + "custom_prompt": ("STRING", {"default": "", "multiline": True, "tooltip": FLF_TOOLTIPS["custom_prompt"]}), + "max_tokens": ("INT", {"default": 384, "min": 64, "max": 2048, "tooltip": FLF_TOOLTIPS["max_tokens"]}), + "keep_model_loaded": ("BOOLEAN", {"default": True, "tooltip": FLF_TOOLTIPS["keep_model_loaded"]}), + "seed": ("INT", {"default": 1, "min": 1, "max": 2**32 - 1, "tooltip": FLF_TOOLTIPS["seed"]}), + }, + "optional": { + "first_frame": ("IMAGE", {"tooltip": FLF_TOOLTIPS["first_frame"]}), + "last_frame": ("IMAGE", {"tooltip": FLF_TOOLTIPS["last_frame"]}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("FLF_PROMPT",) + FUNCTION = "process" + CATEGORY = "IAMCCS/QwenVL" + DESCRIPTION = ( + "Uses QwenVL to analyse the FIRST and LAST frame of a video clip " + "and generate a motion/action description prompt for FLF video generators " + "(WAN SVI Pro, LTX-2 FLF, Wan2.1 i2v, etc.)." + ) + + def process( + self, + model_name, + quantization, + attention_mode, + preset_prompt, + custom_prompt, + max_tokens, + keep_model_loaded, + seed, + first_frame=None, + last_frame=None, + ): + return self.run_flf( + model_name, quantization, preset_prompt, custom_prompt, + first_frame, last_frame, + max_tokens, + temperature=0.6, top_p=0.9, num_beams=1, + repetition_penalty=1.2, + seed=seed, + keep_model_loaded=keep_model_loaded, + attention_mode=attention_mode, + use_torch_compile=False, + device="auto", + ) + + # ------------------------------------------------------------------ + # Advanced version + # ------------------------------------------------------------------ + class IAMCCS_QWEN_VL_FLF_Advanced(_FLFMixin, Base): + """Advanced version of IAMCCS_QWEN_VL_FLF with full parameter control.""" + + @classmethod + def INPUT_TYPES(cls): + models = list(HF_VL_MODELS.keys()) + default_model = models[0] if models else "Qwen2.5-VL-3B-Instruct" + default_prompt = ( + "🎬 Video Action Description (FLF)" + if "🎬 Video Action Description (FLF)" in FLF_PRESET_PROMPTS + else FLF_PRESET_PROMPTS[0] + ) + + num_gpus = torch.cuda.device_count() + gpu_list = [f"cuda:{i}" for i in range(num_gpus)] + device_options = ["auto", "cpu", "mps"] + gpu_list + + return { + "required": { + "model_name": (models, {"default": default_model, "tooltip": FLF_TOOLTIPS["model_name"]}), + "quantization": (Quantization.get_values(), {"default": Quantization.FP16.value, "tooltip": FLF_TOOLTIPS["quantization"]}), + "attention_mode": (ATTENTION_MODES, {"default": "auto", "tooltip": FLF_TOOLTIPS["attention_mode"]}), + "use_torch_compile":("BOOLEAN", {"default": False, "tooltip": FLF_TOOLTIPS["use_torch_compile"]}), + "device": (device_options, {"default": "auto", "tooltip": FLF_TOOLTIPS["device"]}), + "preset_prompt": (FLF_PRESET_PROMPTS, {"default": default_prompt, "tooltip": FLF_TOOLTIPS["preset_prompt"]}), + "custom_prompt": ("STRING", {"default": "", "multiline": True, "tooltip": FLF_TOOLTIPS["custom_prompt"]}), + "max_tokens": ("INT", {"default": 512, "min": 64, "max": 4096, "tooltip": FLF_TOOLTIPS["max_tokens"]}), + "temperature": ("FLOAT", {"default": 0.6, "min": 0.1, "max": 1.0, "step": 0.05, "tooltip": FLF_TOOLTIPS["temperature"]}), + "top_p": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.05, "tooltip": FLF_TOOLTIPS["top_p"]}), + "num_beams": ("INT", {"default": 1, "min": 1, "max": 8, "tooltip": FLF_TOOLTIPS["num_beams"]}), + "repetition_penalty": ("FLOAT", {"default": 1.2, "min": 0.5, "max": 2.0, "step": 0.05, "tooltip": FLF_TOOLTIPS["repetition_penalty"]}), + "keep_model_loaded":("BOOLEAN", {"default": True, "tooltip": FLF_TOOLTIPS["keep_model_loaded"]}), + "seed": ("INT", {"default": 1, "min": 1, "max": 2**32 - 1, "tooltip": FLF_TOOLTIPS["seed"]}), + }, + "optional": { + "first_frame": ("IMAGE", {"tooltip": FLF_TOOLTIPS["first_frame"]}), + "last_frame": ("IMAGE", {"tooltip": FLF_TOOLTIPS["last_frame"]}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("FLF_PROMPT",) + FUNCTION = "process" + CATEGORY = "IAMCCS/QwenVL" + DESCRIPTION = ( + "Advanced version of IAMCCS QwenVL FLF node with full control over " + "generation parameters. Accepts FIRST FRAME + LAST FRAME and outputs " + "an action/motion description prompt for AI video generators." + ) + + def process( + self, + model_name, + quantization, + attention_mode, + use_torch_compile, + device, + preset_prompt, + custom_prompt, + max_tokens, + temperature, + top_p, + num_beams, + repetition_penalty, + keep_model_loaded, + seed, + first_frame=None, + last_frame=None, + ): + return self.run_flf( + model_name, quantization, preset_prompt, custom_prompt, + first_frame, last_frame, + max_tokens, temperature, top_p, num_beams, repetition_penalty, + seed, keep_model_loaded, attention_mode, use_torch_compile, device, + ) + + return IAMCCS_QWEN_VL_FLF, IAMCCS_QWEN_VL_FLF_Advanced + + +# --------------------------------------------------------------------------- +# Module-level instantiation (deferred, with graceful fallback) +# --------------------------------------------------------------------------- + +try: + IAMCCS_QWEN_VL_FLF, IAMCCS_QWEN_VL_FLF_Advanced = _build_node_classes() + + NODE_CLASS_MAPPINGS = { + "IAMCCS_QWEN_VL_FLF": IAMCCS_QWEN_VL_FLF, + "IAMCCS_QWEN_VL_FLF_Advanced": IAMCCS_QWEN_VL_FLF_Advanced, + } + + NODE_DISPLAY_NAME_MAPPINGS = { + "IAMCCS_QWEN_VL_FLF": "QwenVL FLF — First/Last Frame Prompt 🎬", + "IAMCCS_QWEN_VL_FLF_Advanced": "QwenVL FLF — First/Last Frame Prompt (Advanced) 🎬", + } + + print("[IAMCCS] IAMCCS_QWEN_VL_FLF nodes loaded OK") + +except Exception as _err: + print(f"[IAMCCS] WARNING: IAMCCS_QWEN_VL_FLF could not load — {_err}") + print("[IAMCCS] Make sure ComfyUI-QwenVL is installed in custom_nodes/ComfyUI-QwenVL") + + NODE_CLASS_MAPPINGS = {} + NODE_DISPLAY_NAME_MAPPINGS = {} + IAMCCS_QWEN_VL_FLF = None + IAMCCS_QWEN_VL_FLF_Advanced = None diff --git a/iamccs_wan_svipro_motion.py b/iamccs_wan_svipro_motion.py index 532de55..61295ab 100644 --- a/iamccs_wan_svipro_motion.py +++ b/iamccs_wan_svipro_motion.py @@ -11,6 +11,79 @@ import comfy.latent_formats import node_helpers +def _smoothstep(x: torch.Tensor) -> torch.Tensor: + # x in [0,1] + return x * x * (3.0 - 2.0 * x) + + +def _apply_soft_limiter(x: torch.Tensor, *, mode: str, limit: float) -> torch.Tensor: + if mode == "hard": + return x.clamp_(-limit, limit) + if mode == "tanh": + # Smooth limiter: prevents hard saturation artifacts. + # For small values, tanh(x/limit) ≈ x/limit. + return x.div_(limit).tanh_().mul_(limit) + # Fallback + return x.clamp_(-limit, limit) + + +def _center_diff(diff: torch.Tensor, *, mean_mode: str) -> tuple[torch.Tensor, torch.Tensor]: + """Return (diff_centered, diff_mean). + + mean_mode: + - frame_scalar: legacy behavior (mean over channels+spatial). + - per_channel: mean per channel (mean over spatial only). Helps reduce hue shifts. + """ + + if mean_mode == "per_channel": + diff_mean = diff.mean(dim=(3, 4), keepdim=True) + else: + # legacy + diff_mean = diff.mean(dim=(1, 3, 4), keepdim=True) + diff_centered = diff - diff_mean + return diff_centered, diff_mean + + +def _preset_params(safety_preset: str, motion_amplitude: float) -> dict: + # Keep changes non-invasive unless motion is pushed above the common safe zone. + only_if_gt = 1.15 + + legacy = { + "enabled": True, + "only_if_gt": float("inf"), + "mean_mode": "frame_scalar", + "limiter_mode": "hard", + "limiter_limit": 6.0, + "ramp_frames": 0, + } + + safe = { + "enabled": True, + "only_if_gt": only_if_gt, + "mean_mode": "per_channel", + "limiter_mode": "tanh", + "limiter_limit": 6.0, + "ramp_frames": 2, + } + + safer = { + "enabled": True, + "only_if_gt": only_if_gt, + "mean_mode": "per_channel", + "limiter_mode": "tanh", + # slightly tighter limiter to avoid outliers at high motion + "limiter_limit": 5.5, + "ramp_frames": 4, + } + + if safety_preset == "legacy": + return legacy + if safety_preset == "safer": + return safer + # default + return safe + + class IAMCCS_WanImageMotion: @classmethod def INPUT_TYPES(cls): @@ -20,7 +93,8 @@ class IAMCCS_WanImageMotion: "negative": ("CONDITIONING",), "length": ("INT", {"default": 81, "min": 1, "max": 16384, "step": 4}), "anchor_samples": ("LATENT",), - "motion_latent_count": ("INT", {"default": 1, "min": 0, "max": 128, "step": 1}), + # Match FLF/SVI Pro semantics: typical 0-16. + "motion_latent_count": ("INT", {"default": 1, "min": 0, "max": 16, "step": 1}), "motion": ("FLOAT", {"default": 1.15, "min": 1.0, "max": 2.0, "step": 0.05}), "motion_mode": ( [ @@ -37,7 +111,8 @@ class IAMCCS_WanImageMotion: "fp32", "normal", ], - {"default": "auto"}, + # FLF reference node allocates empty latent as fp32 by default. + {"default": "fp32"}, ), "vram_profile": ( [ @@ -50,6 +125,22 @@ class IAMCCS_WanImageMotion: {"default": "normal"}, ), "include_padding_in_motion": ("BOOLEAN", {"default": False}), + # Keep this at the end to preserve existing widgets_values indexing in saved workflows. + "safety_preset": ( + [ + "safe", + "safer", + "legacy", + ], + { + "default": "safe", + "tooltip": ( + "Safe preset reduces color/seam artifacts when motion > 1.15. " + "It applies per-channel stabilization, smooth limiter, and a short ramp. " + "Set legacy to use the original hard-clamp behavior." + ), + }, + ), }, "optional": { "prev_samples": ("LATENT",), @@ -67,6 +158,9 @@ class IAMCCS_WanImageMotion: if latent_precision == "normal": # Backward-compat alias for older workflows. return anchor_dtype + if latent_precision == "auto": + # Prefer fp32 for 1:1 compatibility with the FLF reference node. + return torch.float32 if latent_precision == "fp32": return torch.float32 if latent_precision == "fp16": @@ -75,7 +169,7 @@ class IAMCCS_WanImageMotion: def _apply_motion_amplitude(self, image_cond_latent: torch.Tensor, *, real_latents: int, anchor_latents: int, motion_latents: int, motion_amplitude: float, motion_mode: str, - vram_profile: str) -> torch.Tensor: + vram_profile: str, safety_preset: str = "safe") -> torch.Tensor: if motion_amplitude is None or motion_amplitude <= 1.0: return image_cond_latent @@ -84,18 +178,41 @@ class IAMCCS_WanImageMotion: base_latent = image_cond_latent[:, :, 0:1] # first latent frame - def _scale_slice_gpu(view_slice: torch.Tensor) -> torch.Tensor: + preset = _preset_params(safety_preset, motion_amplitude) + # Non-invasive: if motion is within the usual safe zone, keep legacy behavior. + # This preserves 1:1 results for typical workflows. + if motion_amplitude <= preset["only_if_gt"]: + preset = _preset_params("legacy", motion_amplitude) + + def _scale_slice_gpu(view_slice: torch.Tensor, *, gain: torch.Tensor | float) -> torch.Tensor: # view_slice: [B,C,T,H,W] # VRAM-optimized variant: keep only one full-sized temporary tensor. with torch.no_grad(): diff = view_slice - base_latent - diff_mean = diff.mean(dim=(1, 3, 4), keepdim=True) - diff.sub_(diff_mean) - diff.mul_(motion_amplitude) - diff.add_(diff_mean) - diff.add_(base_latent) - diff.clamp_(-6, 6) - return diff + diff_centered, diff_mean = _center_diff(diff, mean_mode=preset["mean_mode"]) + diff_centered.mul_(gain) + out_local = diff_centered.add_(diff_mean).add_(base_latent) + _apply_soft_limiter(out_local, mode=preset["limiter_mode"], limit=float(preset["limiter_limit"])) + return out_local + + def _gain_weights(start: int, end: int) -> torch.Tensor | float: + # Returns broadcastable gain weights for the slice. + # gain = 1 + (motion-1)*w(t) + ramp_frames = int(preset["ramp_frames"]) + if ramp_frames <= 0: + return float(motion_amplitude) + tcount = max(0, end - start) + if tcount <= 0: + return float(motion_amplitude) + # ramp up only at the beginning of the boosted range + ramp = min(ramp_frames, tcount) + w = torch.ones((tcount,), device=image_cond_latent.device, dtype=image_cond_latent.dtype) + if ramp > 0: + # 0..1 over ramp + x = torch.linspace(0.0, 1.0, steps=ramp, device=w.device, dtype=w.dtype) + w[:ramp] = _smoothstep(x) + gain = 1.0 + (motion_amplitude - 1.0) * w + return gain.view(1, 1, tcount, 1, 1) def _apply_to_range(start: int, end: int) -> torch.Tensor: # Applies scaling to out[:, :, start:end] according to VRAM profile. @@ -105,7 +222,8 @@ class IAMCCS_WanImageMotion: return out if vram_profile == "normal": - out[:, :, start:end] = _scale_slice_gpu(out[:, :, start:end]) + gain = _gain_weights(start, end) + out[:, :, start:end] = _scale_slice_gpu(out[:, :, start:end], gain=gain) return out if vram_profile in ("chunked_blocks_2", "chunked_blocks_4"): @@ -113,13 +231,15 @@ class IAMCCS_WanImageMotion: t = start while t < end: t2 = min(end, t + block) - out[:, :, t:t2] = _scale_slice_gpu(out[:, :, t:t2]) + gain = _gain_weights(t, t2) + out[:, :, t:t2] = _scale_slice_gpu(out[:, :, t:t2], gain=gain) t = t2 return out if vram_profile == "loop_per_frame (lowest_vram)": for t in range(start, end): - out[:, :, t:t+1] = _scale_slice_gpu(out[:, :, t:t+1]) + gain = _gain_weights(t, t + 1) + out[:, :, t:t+1] = _scale_slice_gpu(out[:, :, t:t+1], gain=gain) return out if vram_profile == "cpu_offload (slowest)": @@ -130,17 +250,27 @@ class IAMCCS_WanImageMotion: base_cpu = base_latent.detach().to("cpu") slice_cpu = out[:, :, start:end].detach().to("cpu") diff = slice_cpu - base_cpu - diff_mean = diff.mean(dim=(1, 3, 4), keepdim=True) - diff.sub_(diff_mean) - diff.mul_(motion_amplitude) - diff.add_(diff_mean) - diff.add_(base_cpu) - diff.clamp_(-6, 6) - out[:, :, start:end] = diff.to(device) + diff_centered, diff_mean = _center_diff(diff, mean_mode=preset["mean_mode"]) + # gain weights are computed on the target device; rebuild on CPU + ramp_frames = int(preset["ramp_frames"]) + tcount = max(0, end - start) + if ramp_frames > 0 and tcount > 0: + ramp = min(ramp_frames, tcount) + w = torch.ones((tcount,), device=diff_centered.device, dtype=diff_centered.dtype) + x = torch.linspace(0.0, 1.0, steps=ramp, device=w.device, dtype=w.dtype) + w[:ramp] = _smoothstep(x) + gain = (1.0 + (motion_amplitude - 1.0) * w).view(1, 1, tcount, 1, 1) + else: + gain = float(motion_amplitude) + diff_centered.mul_(gain) + out_cpu = diff_centered.add_(diff_mean).add_(base_cpu) + _apply_soft_limiter(out_cpu, mode=preset["limiter_mode"], limit=float(preset["limiter_limit"])) + out[:, :, start:end] = out_cpu.to(device) return out # Fallback - out[:, :, start:end] = _scale_slice_gpu(out[:, :, start:end]) + gain = _gain_weights(start, end) + out[:, :, start:end] = _scale_slice_gpu(out[:, :, start:end], gain=gain) return out # Avoid touching padding: operate only within [0:real_latents) @@ -168,9 +298,10 @@ class IAMCCS_WanImageMotion: def apply(self, positive, negative, length, anchor_samples, motion_latent_count, motion, motion_mode, add_reference_latents, latent_precision, vram_profile, include_padding_in_motion, - prev_samples=None): + safety_preset="safe", prev_samples=None): with torch.no_grad(): - anchor_latent = anchor_samples["samples"] + # Clone to prevent in-place motion amplitude writes from corrupting the caller's tensor. + anchor_latent = anchor_samples["samples"].clone() B, C, T_anchor, H, W = anchor_latent.shape @@ -201,9 +332,18 @@ class IAMCCS_WanImageMotion: image_cond_latent = torch.cat([anchor_latent, motion_latent], dim=2) padding_size = max(0, padding_size) - padding = torch.zeros(1, C, padding_size, H, W, dtype=dtype, device=device) - padding = comfy.latent_formats.Wan21().process_out(padding) - image_cond_latent = torch.cat([image_cond_latent, padding], dim=2) + if padding_size > 0: + padding = torch.zeros(B, C, padding_size, H, W, dtype=dtype, device=device) + padding = comfy.latent_formats.Wan21().process_out(padding) + image_cond_latent = torch.cat([image_cond_latent, padding], dim=2) + + # FLF/SVI reference behavior: ensure exact temporal length. + if image_cond_latent.shape[2] > total_latents: + image_cond_latent = image_cond_latent[:, :, :total_latents] + elif image_cond_latent.shape[2] < total_latents: + # Safety: if something went off, truncate/pad has already handled it, + # but keep a hard guard. + image_cond_latent = image_cond_latent[:, :, :total_latents] # Apply motion amplitude before injecting into conditioning effective_latents = total_latents if include_padding_in_motion else min(total_latents, T_anchor + T_motion) @@ -285,6 +425,7 @@ class IAMCCS_WanImageMotion: motion_amplitude=motion, motion_mode=motion_mode_effective, vram_profile=vram_profile, + safety_preset=safety_preset, ) mask = torch.ones((1, 1, empty_latent.shape[2], H, W), device=device, dtype=dtype) @@ -312,10 +453,440 @@ class IAMCCS_WanImageMotion: return (positive, negative, out_latent) +class WanImageMotionPro: + """WanImageMotionPro + + Combines IAMCCS_WanImageMotion motion amplitude control with FLF-style + (First/Last Frame) hard lock via optional end_samples. + + Behavior: + - Start: anchor_samples + optional motion tail from prev_samples. + - Motion: apply motion amplitude scaling (VRAM-aware) as in IAMCCS_WanImageMotion. + - End: overwrite last temporal latent slots with end_samples, then lock them + via concat_mask (FLF-style control). + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + # Keep the same socket-style inputs as WanImageToVideoSVIProFLF + # so existing FLF workflows can be migrated with minimal friction. + "positive": ("CONDITIONING",), + "negative": ("CONDITIONING",), + "length": ("INT", {"default": 81, "min": 1, "max": 16384, "step": 4}), + "anchor_samples": ("LATENT",), + # Match FLF reference node range. + "motion_latent_count": ("INT", {"default": 1, "min": 0, "max": 16, "step": 1}), + "motion": ("FLOAT", {"default": 1.15, "min": 1.0, "max": 2.0, "step": 0.05}), + "motion_mode": ( + [ + "motion_only (prev_samples)", + "all_nonfirst (anchor+motion)", + ], + {"default": "motion_only (prev_samples)"}, + ), + "add_reference_latents": ("BOOLEAN", {"default": False}), + "latent_precision": ( + [ + "auto", + "fp16", + "fp32", + "normal", + ], + {"default": "fp32"}, + ), + "vram_profile": ( + [ + "normal", + "chunked_blocks_2", + "chunked_blocks_4", + "loop_per_frame (lowest_vram)", + "cpu_offload (slowest)", + ], + {"default": "normal"}, + ), + "include_padding_in_motion": ("BOOLEAN", {"default": False}), + # Keep this at the end to preserve existing widgets_values indexing in saved workflows. + "safety_preset": ( + [ + "safe", + "safer", + "legacy", + ], + { + "default": "safe", + "tooltip": ( + "Safe preset reduces color/seam artifacts when motion > 1.15. " + "Set legacy to use original hard-clamp behavior." + ), + }, + ), + }, + "optional": { + # prev_samples is optional – mirrors original FLF node and IAMCCS_WanImageMotion. + # apply() already handles None gracefully. + "prev_samples": ("LATENT",), + "end_samples": ("LATENT",), + }, + } + + RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT") + RETURN_NAMES = ("positive", "negative", "latent") + FUNCTION = "apply" + CATEGORY = "IAMCCS/video" + + _log = logging.getLogger("IAMCCS.WanImageMotionPro") + + def _pick_empty_latent_dtype(self, anchor_dtype: torch.dtype, latent_precision: str) -> torch.dtype: + # Keep 1:1 behavior with IAMCCS_WanImageMotion. + if latent_precision == "normal": + return anchor_dtype + if latent_precision == "auto": + return torch.float32 + if latent_precision == "fp32": + return torch.float32 + if latent_precision == "fp16": + return torch.float16 + return anchor_dtype + + def _apply_motion_amplitude( + self, + image_cond_latent: torch.Tensor, + *, + real_latents: int, + anchor_latents: int, + motion_latents: int, + motion_amplitude: float, + motion_mode: str, + vram_profile: str, + safety_preset: str = "safe", + ) -> torch.Tensor: + # Reuse the exact logic from IAMCCS_WanImageMotion (copy to keep node self-contained). + if motion_amplitude is None or motion_amplitude <= 1.0: + return image_cond_latent + + if image_cond_latent.shape[2] <= 1: + return image_cond_latent + + base_latent = image_cond_latent[:, :, 0:1] + + preset = _preset_params(safety_preset, motion_amplitude) + if motion_amplitude <= preset["only_if_gt"]: + preset = _preset_params("legacy", motion_amplitude) + + def _scale_slice_gpu(view_slice: torch.Tensor, *, gain: torch.Tensor | float) -> torch.Tensor: + with torch.no_grad(): + diff = view_slice - base_latent + diff_centered, diff_mean = _center_diff(diff, mean_mode=preset["mean_mode"]) + diff_centered.mul_(gain) + out_local = diff_centered.add_(diff_mean).add_(base_latent) + _apply_soft_limiter(out_local, mode=preset["limiter_mode"], limit=float(preset["limiter_limit"])) + return out_local + + def _gain_weights(start: int, end: int) -> torch.Tensor | float: + ramp_frames = int(preset["ramp_frames"]) + if ramp_frames <= 0: + return float(motion_amplitude) + tcount = max(0, end - start) + if tcount <= 0: + return float(motion_amplitude) + ramp = min(ramp_frames, tcount) + w = torch.ones((tcount,), device=image_cond_latent.device, dtype=image_cond_latent.dtype) + if ramp > 0: + x = torch.linspace(0.0, 1.0, steps=ramp, device=w.device, dtype=w.dtype) + w[:ramp] = _smoothstep(x) + gain = 1.0 + (motion_amplitude - 1.0) * w + return gain.view(1, 1, tcount, 1, 1) + + def _apply_to_range(start: int, end: int) -> torch.Tensor: + if end <= start: + return out + + if vram_profile == "normal": + gain = _gain_weights(start, end) + out[:, :, start:end] = _scale_slice_gpu(out[:, :, start:end], gain=gain) + return out + + if vram_profile in ("chunked_blocks_2", "chunked_blocks_4"): + block = 2 if vram_profile == "chunked_blocks_2" else 4 + t = start + while t < end: + t2 = min(end, t + block) + gain = _gain_weights(t, t2) + out[:, :, t:t2] = _scale_slice_gpu(out[:, :, t:t2], gain=gain) + t = t2 + return out + + if vram_profile == "loop_per_frame (lowest_vram)": + for t in range(start, end): + gain = _gain_weights(t, t + 1) + out[:, :, t:t + 1] = _scale_slice_gpu(out[:, :, t:t + 1], gain=gain) + return out + + if vram_profile == "cpu_offload (slowest)": + with torch.no_grad(): + device = out.device + base_cpu = base_latent.detach().to("cpu") + slice_cpu = out[:, :, start:end].detach().to("cpu") + diff = slice_cpu - base_cpu + diff_centered, diff_mean = _center_diff(diff, mean_mode=preset["mean_mode"]) + ramp_frames = int(preset["ramp_frames"]) + tcount = max(0, end - start) + if ramp_frames > 0 and tcount > 0: + ramp = min(ramp_frames, tcount) + w = torch.ones((tcount,), device=diff_centered.device, dtype=diff_centered.dtype) + x = torch.linspace(0.0, 1.0, steps=ramp, device=w.device, dtype=w.dtype) + w[:ramp] = _smoothstep(x) + gain = (1.0 + (motion_amplitude - 1.0) * w).view(1, 1, tcount, 1, 1) + else: + gain = float(motion_amplitude) + diff_centered.mul_(gain) + out_cpu = diff_centered.add_(diff_mean).add_(base_cpu) + _apply_soft_limiter(out_cpu, mode=preset["limiter_mode"], limit=float(preset["limiter_limit"])) + out[:, :, start:end] = out_cpu.to(device) + return out + + gain = _gain_weights(start, end) + out[:, :, start:end] = _scale_slice_gpu(out[:, :, start:end], gain=gain) + return out + + real_latents = max(0, min(real_latents, image_cond_latent.shape[2])) + if real_latents <= 1: + return image_cond_latent + + out = image_cond_latent + + if motion_mode == "motion_only (prev_samples)": + if motion_latents <= 0: + return out + + start = anchor_latents + end = min(anchor_latents + motion_latents, real_latents) + if end <= start: + return out + + return _apply_to_range(start, end) + + start = 1 + end = real_latents + return _apply_to_range(start, end) + + def apply( + self, + positive, + negative, + length, + anchor_samples, + motion_latent_count, + motion, + motion_mode, + add_reference_latents, + latent_precision, + vram_profile, + include_padding_in_motion, + safety_preset="safe", + prev_samples=None, + end_samples=None, + ): + with torch.no_grad(): + # Clone to prevent in-place motion amplitude writes from corrupting the caller's tensor. + anchor_latent = anchor_samples["samples"].clone() + B, C, T_anchor, H, W = anchor_latent.shape + + total_latents = (length - 1) // 4 + 1 + + device = anchor_latent.device + dtype = anchor_latent.dtype + + empty_latent_dtype = self._pick_empty_latent_dtype(dtype, latent_precision) + empty_latent = torch.zeros( + [B, 16, total_latents, H, W], + device=comfy.model_management.intermediate_device(), + dtype=empty_latent_dtype, + ) + + motion_latent = None + T_motion = 0 + # In the original FLF node, prev_samples is a required socket. + # If a workflow leaves it disconnected, ComfyUI may pass None. + has_prev = prev_samples is not None and motion_latent_count != 0 + + if prev_samples is None or motion_latent_count == 0: + padding_size = total_latents - T_anchor + image_cond_latent = anchor_latent + else: + motion_latent = prev_samples["samples"][:, :, -motion_latent_count:] + T_motion = motion_latent.shape[2] + padding_size = total_latents - T_anchor - T_motion + image_cond_latent = torch.cat([anchor_latent, motion_latent], dim=2) + + padding_size = max(0, padding_size) + if padding_size > 0: + padding = torch.zeros(B, C, padding_size, H, W, dtype=dtype, device=device) + padding = comfy.latent_formats.Wan21().process_out(padding) + image_cond_latent = torch.cat([image_cond_latent, padding], dim=2) + + # FLF/SVI reference behavior: enforce exact temporal length. + if image_cond_latent.shape[2] > total_latents: + image_cond_latent = image_cond_latent[:, :, :total_latents] + elif image_cond_latent.shape[2] < total_latents: + image_cond_latent = image_cond_latent[:, :, :total_latents] + + # Pre-compute end_t_fix so we can exclude the end-locked zone from motion amplitude. + # Motion should NOT touch slots that will be hard-locked to end_samples: scaling those + # intermediate latents would generate noise that hurts the model's first→last interpolation. + end_t_fix_early = 0 + if end_samples is not None: + _e = end_samples["samples"] + if ( + _e.shape[1] == C + and _e.shape[3] == H + and _e.shape[4] == W + ): + end_t_fix_early = min(_e.shape[2], total_latents) + + # Motion boost applied before FLF overwrite. + # Cap effective_latents so motion never reaches into the end-locked zone. + effective_latents_base = total_latents if include_padding_in_motion else min(total_latents, T_anchor + T_motion) + effective_latents = max(1, min(effective_latents_base, total_latents - end_t_fix_early)) + + motion_mode_effective = motion_mode + if motion_mode == "motion_only (prev_samples)" and T_motion == 0 and include_padding_in_motion: + motion_mode_effective = "all_nonfirst (anchor+motion)" + + try: + free_vram, total_vram = comfy.model_management.get_free_memory(device) + except Exception: + free_vram, total_vram = None, None + + self._log.info( + "[WanImageMotionPro] length=%s -> total_latents=%s | motion=%s | mode=%s | vram_profile=%s | latent_precision=%s | add_reference_latents=%s | include_padding_in_motion=%s", + length, + total_latents, + motion, + motion_mode, + vram_profile, + latent_precision, + add_reference_latents, + include_padding_in_motion, + ) + self._log.info( + "[WanImageMotionPro] anchor: B=%s C=%s T=%s H=%s W=%s dtype=%s device=%s | prev=%s motion_latent_count=%s T_motion=%s | padding_size=%s | end_samples=%s", + B, + C, + T_anchor, + H, + W, + str(dtype).replace("torch.", ""), + str(device), + has_prev, + motion_latent_count, + T_motion, + padding_size, + end_samples is not None, + ) + if free_vram is not None: + self._log.info("[WanImageMotionPro] free_vram=%s total_vram=%s", free_vram, total_vram) + + if motion_mode_effective == "motion_only (prev_samples)": + motion_start = T_anchor + motion_end = min(T_anchor + T_motion, effective_latents) + else: + motion_start = 1 + motion_end = effective_latents + + motion_frames_count = max(0, motion_end - motion_start) + self._log.info( + "[WanImageMotionPro] motion_range=[%s:%s] (effective_latents=%s) padding_included=%s", + motion_start, + motion_end, + effective_latents, + include_padding_in_motion, + ) + if motion_frames_count == 0: + self._log.warning( + "[WanImageMotionPro] WARNING: motion_range is EMPTY (no frames will be modified). " + "Enable include_padding_in_motion or provide prev_samples with motion_latent_count > 0." + ) + else: + self._log.info( + "[WanImageMotionPro] Motion boost applies to %s frame(s) amplitude=%.2f", + motion_frames_count, + motion, + ) + + image_cond_latent = self._apply_motion_amplitude( + image_cond_latent, + real_latents=effective_latents, + anchor_latents=T_anchor, + motion_latents=T_motion, + motion_amplitude=motion, + motion_mode=motion_mode_effective, + vram_profile=vram_profile, + safety_preset=safety_preset, + ) + + # FLF end lock: overwrite last slots with end_samples (if provided). + end_t_fix = 0 + if end_samples is not None: + # Clone to prevent mutations from affecting the caller's tensor. + end_latent = end_samples["samples"].clone() + + if end_latent.shape[0] == 1 and B > 1: + end_latent = end_latent.repeat(B, 1, 1, 1, 1) + + if ( + end_latent.shape[1] == C + and end_latent.shape[3] == H + and end_latent.shape[4] == W + ): + T_end = end_latent.shape[2] + end_t_fix = min(T_end, total_latents) + if end_t_fix > 0: + image_cond_latent[:, :, -end_t_fix:] = end_latent[:, :, -end_t_fix:] + else: + end_t_fix = 0 + self._log.warning( + "[WanImageMotionPro] end_samples shape mismatch, skipping end lock. end=%s anchor=%s", + tuple(end_latent.shape), + tuple(anchor_latent.shape), + ) + + # Mask: lock first slot + lock last end_t_fix slots. + mask = torch.ones((1, 1, total_latents, H, W), device=device, dtype=dtype) + mask[:, :, :1] = 0.0 + if end_t_fix > 0: + mask[:, :, -end_t_fix:] = 0.0 + + positive = node_helpers.conditioning_set_values( + positive, {"concat_latent_image": image_cond_latent, "concat_mask": mask} + ) + negative = node_helpers.conditioning_set_values( + negative, {"concat_latent_image": image_cond_latent, "concat_mask": mask} + ) + + if add_reference_latents: + ref_latent = anchor_latent[:, :, 0:1] + positive = node_helpers.conditioning_set_values( + positive, {"reference_latents": [ref_latent]}, append=True + ) + negative = node_helpers.conditioning_set_values( + negative, {"reference_latents": [torch.zeros_like(ref_latent)]}, append=True + ) + + out_latent = {"samples": empty_latent} + return (positive, negative, out_latent) + + NODE_CLASS_MAPPINGS = { "IAMCCS_WanImageMotion": IAMCCS_WanImageMotion, + "WanImageMotionPro": WanImageMotionPro, + "IAMCCS_WanImageMotionPro": WanImageMotionPro, } NODE_DISPLAY_NAME_MAPPINGS = { "IAMCCS_WanImageMotion": "IAMCCS WanImageMotion", + "WanImageMotionPro": "WanImageMotionPro", + "IAMCCS_WanImageMotionPro": "WanImageMotionPro", } diff --git a/version.json b/version.json index e4e344e..f646a95 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "name": "iamccs-nodes", - "version": "1.3.4", + "version": "1.3.5", "author": "Carmine Cristallo Scalzi (IAMCCS)", "description": "IAMCCS nodes for ComfyUI: IAMCCS echosystem for ComfyUI, nodes 4 LoRA, WAN 2.2, WAN 2.1 and LTX-2 pipelines." } diff --git a/web/iamccs_autolink_converter.js b/web/iamccs_autolink_converter.js index 1679fc8..86bb81e 100644 --- a/web/iamccs_autolink_converter.js +++ b/web/iamccs_autolink_converter.js @@ -153,6 +153,20 @@ function normalizeAutolinkIOSlots(graph, node, { wantInputs = 0, wantOutputs = 0 if (!node.inputs) node.inputs = []; if (!node.outputs) node.outputs = []; + // Remove ALL inputs when none are wanted (fixes Get nodes that were incorrectly + // serialized with stale input slots from old buggy workflows or the queue patch). + if (wantInputs === 0 && node.inputs.length > 0) { + try { + for (let i = node.inputs.length - 1; i >= 0; i--) { + try { if (typeof node.disconnectInput === "function") node.disconnectInput(i); } catch {} + try { + if (typeof node.removeInput === "function") node.removeInput(i); + else node.inputs.splice(i, 1); + } catch {} + } + } catch {} + } + // Ensure at least one slot exists when requested if (wantInputs > 0 && node.inputs.length === 0 && typeof node.addInput === "function") { node.addInput("*", "*"); @@ -1350,6 +1364,10 @@ app.registerExtension({ if (nodeData?.name === SET_TYPE) { const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { + // --- MUST be set BEFORE anything else so ComfyUI skips this node + // during graphToPrompt serialization and uses getInputLink chain --- + this.isVirtualNode = true; + const result = onNodeCreated?.apply(this, arguments); const node = this; @@ -1411,38 +1429,67 @@ app.registerExtension({ // Input/output: normalizza workflow vecchi (che possono avere input duplicati) normalizeAutolinkIOSlots(app.graph, node, { wantInputs: 1, wantOutputs: 1 }); - - // Callback quando si collega - this.onConnectionsChange = function(slotType, slot, isConnect, link_info) { - if (slotType === 1 && isConnect && link_info) { - const fromNode = app.graph.getNodeById(link_info.origin_id); - if (fromNode && fromNode.outputs && fromNode.outputs[link_info.origin_slot]) { - const outputType = fromNode.outputs[link_info.origin_slot].type; - // Usa sempre un nome leggibile e stabile (slot-name), non il tipo puro. - // Questo produce base tipo "model"/"image" e poi lo rendiamo unico: model_0, image_2, ... - let suggestedBase = getSlotName(fromNode, link_info.origin_slot, true); - if (!isValidAutolinkKey(suggestedBase)) { - if (outputType && outputType !== "*") suggestedBase = String(outputType).trim().toLowerCase(); - else suggestedBase = `output_${link_info.origin_slot}`; + // --- KJ-style update: propagate type changes to all matching Get nodes --- + this._iamccsUpdateGetters = function() { + try { + const key = getAutolinkKey(node); + if (!key) return; + const curType = node.inputs?.[0]?.type || "*"; + const gets = _iamccsGraphNodes(app.graph).filter( + n => n?.type === GET_TYPE && getAutolinkKey(n) === key + ); + for (const g of gets) { + if (g.outputs?.[0]) { + g.outputs[0].type = curType; + g.outputs[0].name = curType; } - - // Imposta tipo - node.inputs[0].type = outputType; - node.outputs[0].type = outputType; + // Validate and remove type-incompatible links from each Get + try { g.validateLinks?.(); } catch {} + } + } catch {} + }; - // Se il converter ha già impostato un nome unico, NON sovrascriverlo qui. - // Auto-fill solo quando il widget è vuoto. - const currentKey = getAutolinkKey(node); - const desiredKey = isValidAutolinkKey(currentKey) - ? currentKey - : makeUniqueAutolinkSetName(app.graph, suggestedBase); + // Callback quando si collega / scollega + this.onConnectionsChange = function(slotType, slot, isConnect, link_info) { + if (slotType === 1) { // input slot changed + if (isConnect && link_info) { + const fromNode = app.graph.getNodeById + ? app.graph.getNodeById(link_info.origin_id) + : getNodeById(app.graph, link_info.origin_id); + if (fromNode?.outputs?.[link_info.origin_slot]) { + const outputType = fromNode.outputs[link_info.origin_slot].type; - // Imposta chiave + UI + porta coerenti - setAutolinkKeyAndTitle(node, desiredKey); + // Stabilize a name using the slot label (not the raw type) + let suggestedBase = getSlotName(fromNode, link_info.origin_slot, true); + if (!isValidAutolinkKey(suggestedBase)) { + if (outputType && outputType !== "*") + suggestedBase = String(outputType).trim().toLowerCase(); + else + suggestedBase = `output_${link_info.origin_slot}`; + } - // Applica colore testo titolo (se configurato) - applyNodeTitleTextColor(node, getCurrentColorTitlesMode()); + // Update type on both slots + if (node.inputs?.[0]) node.inputs[0].type = outputType; + if (node.outputs?.[0]) node.outputs[0].type = outputType; + + // Auto-fill name only when the widget is still empty + const currentKey = getAutolinkKey(node); + const desiredKey = isValidAutolinkKey(currentKey) + ? currentKey + : makeUniqueAutolinkSetName(app.graph, suggestedBase); + + setAutolinkKeyAndTitle(node, desiredKey); + applyNodeTitleTextColor(node, getCurrentColorTitlesMode()); + + // Propagate new type to all Get nodes sharing our key + node._iamccsUpdateGetters?.(); + } + } else if (!isConnect) { + // On disconnect: reset type to wildcard + if (node.inputs?.[0]) { node.inputs[0].type = "*"; node.inputs[0].name = "*"; } + if (node.outputs?.[0]) { node.outputs[0].type = "*"; node.outputs[0].name = "*"; } + node._iamccsUpdateGetters?.(); } } }; @@ -1469,23 +1516,26 @@ app.registerExtension({ if (String(rawName ?? "").trim() === "*") setWidgetValue(node, "name", ""); if (String(node.title ?? "").trim() === "*") node.title = "Set AutoLink"; } catch {} - - // Nodo virtuale - non serializza per il prompt + + // isVirtualNode already set at top – keep here for safety (serialization guard) this.isVirtualNode = true; - + return result; }; } - - // Get node - come KJ GetNode + + // Get node - 1:1 KJ GetNode pattern with IAMCCS styling if (nodeData?.name === GET_TYPE) { const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { + // --- MUST be set BEFORE anything else --- + this.isVirtualNode = true; + const result = onNodeCreated?.apply(this, arguments); const node = this; - - // Combo dinamico con lista Set disponibili - this.addWidget("combo", "name", "", (value) => { + + // Combo dinamico con lista Set disponibili (identical to KJ "Constant" combo) + this.addWidget("combo", "name", "", () => { node.onRename(); }, { values: () => { @@ -1494,22 +1544,64 @@ app.registerExtension({ } }); - // Normalizza output duplicati su workflow vecchi + // Normalizza output/input duplicati su workflow vecchi + // wantInputs: 0 → any stale inputs will be stripped by normalizeAutolinkIOSlots normalizeAutolinkIOSlots(app.graph, node, { wantInputs: 0, wantOutputs: 1 }); - + + // --- KJ-style: remove links whose type no longer matches our output --- + this.validateLinks = function() { + try { + if (!node.outputs?.[0]) return; + const outType = node.outputs[0].type; + if (!outType || outType === "*") return; + const links = node.outputs[0].links; + if (!Array.isArray(links) || links.length === 0) return; + for (const linkId of [...links]) { + const link = _iamccsGetLink(app.graph, linkId); + if (!link) continue; + const lt = link.type; + if (lt && lt !== "*" && lt !== outType && + !lt.split(",").includes(outType) && + !outType.split(",").includes(lt)) { + try { app.graph.removeLink(linkId); } catch {} + } + } + } catch {} + }; + + this.setType = function(type) { + if (!node.outputs?.[0]) return; + node.outputs[0].name = type; + node.outputs[0].type = type; + node.validateLinks(); + }; + + // KJ-style setName: updates widget and triggers onRename + this.setName = function(name) { + setWidgetValue(node, "name", name); + node.onRename(); + }; + this.onRename = function() { const setterName = getAutolinkKey(node); - const setter = _iamccsGraphNodes(app.graph).find(n => + const setter = _iamccsGraphNodes(app.graph).find(n => n.type === SET_TYPE && getAutolinkKey(n) === setterName ); - - if (setter) { - const linkType = setter.outputs[0].type; - node.outputs[0].type = linkType; - node.outputs[0].name = linkType; - node.title = setterName; + if (setter) { + const linkType = setter.inputs?.[0]?.type || "*"; + node.setType(linkType); + node.title = setterName; applyNodeTitleTextColor(node, getCurrentColorTitlesMode()); + } else { + node.setType("*"); + } + }; + + // On output connection change, validate link types (KJ pattern) + this.onConnectionsChange = function(slotType /*1=input,2=output*/, slot, isConnect) { + if (slotType === 2) { + node.validateLinks(); } }; @@ -1517,31 +1609,34 @@ app.registerExtension({ try { if (String(node.title ?? "").trim() === "*") node.title = "Get AutoLink"; } catch {} - // (removed) per-node overlay title drawing; native title is colored via canvas hook - - // Override getInputLink per prendere da Set + + // getInputLink: called by ComfyUI graphToPrompt to resolve the real source link. + // ComfyUI calls this with `slot` = the OUTPUT slot index of this GetNode (always 0). + // We look up our paired SetNode and return the link on its input slot 0, + // which has origin_id = the real upstream node (not virtual). this.getInputLink = function(slot) { - const setterName = getAutolinkKey(node); - const setter = _iamccsGraphNodes(app.graph).find(n => - n.type === SET_TYPE && getAutolinkKey(n) === setterName - ); - - if (setter) { - const slotInfo = setter.inputs[slot]; - if (slotInfo) { - const linkId = slotInfo.link != null - ? slotInfo.link - : (Array.isArray(slotInfo.links) ? slotInfo.links[0] : null); - const link = _iamccsGetLink(app.graph, linkId); - return link || null; - } + try { + const setterName = getAutolinkKey(node); + if (!setterName) return null; + const setter = _iamccsGraphNodes(app.graph).find(n => + n.type === SET_TYPE && getAutolinkKey(n) === setterName + ); + if (!setter?.inputs?.length) return null; + // slot index maps to the Set's input (both nodes use slot 0) + const slotInfo = setter.inputs[0]; + if (!slotInfo) return null; + const linkId = slotInfo.link != null + ? slotInfo.link + : (Array.isArray(slotInfo.links) ? slotInfo.links[0] : null); + return _iamccsGetLink(app.graph, linkId) || null; + } catch { + return null; } - return null; }; - - // Nodo virtuale - non serializza per il prompt + + // isVirtualNode redundant here (set at top) but kept as an insurance belt this.isVirtualNode = true; - + return result; }; } @@ -2538,7 +2633,7 @@ function convertAllLinks( const occupiedSetPositions = new Set(); const occupiedGetPositions = new Set(); const createdSets = new Map(); - + // Traccia i nomi dei Set già esistenti/creati per evitare duplicati // - Preserva nomi numerati come "model_0" (non li riduce a "model") const usedExactSetNames = new Set(); @@ -2550,6 +2645,18 @@ function convertAllLinks( if (name && String(name).trim()) usedExactSetNames.add(String(name).trim()); } + // Build a map of (srcNodeId, originSlot) → existing SetNode so we can REUSE + // a Set that already consumes from that output instead of creating a duplicate. + // This prevents the "double Set for same source slot" bug when Convert is called + // on a partially-converted graph. + const existingSetBySourceKey = new Map(); + for (const es of existingSets) { + const inLink = es?.inputs?.[0]?.link != null ? _iamccsGetLink(graph, es.inputs[0].link) : null; + if (!inLink) continue; + const sk = `${inLink.origin_id}_${inLink.origin_slot}`; + if (!existingSetBySourceKey.has(sk)) existingSetBySourceKey.set(sk, es); + } + function makeUniqueSetName(desiredName) { const desired = String(desiredName ?? "").trim(); if (!desired) { @@ -2582,83 +2689,96 @@ function convertAllLinks( return candidate; } - console.log(`[IAMCCS AutoLink] Creating ${linksByOrigin.size} Set nodes...`); - - // Crea Set nodes (uno per origine) + console.log(`[IAMCCS AutoLink] Creating/reusing ${linksByOrigin.size} Set nodes...`); + + // Crea Set nodes (uno per origine), RIUTILIZZANDO Set già esistenti per la stessa sorgente. + // Questo evita il bug "doppio Set per lo stesso slot" quando Convert viene chiamato + // su un grafo parzialmente convertito. for (const [key, originData] of linksByOrigin) { const { srcNode, originSlot, outputName, destinations } = originData; - - const setPos = findFreePosition( - graph, - srcNode.pos[0] + (srcNode.size?.[0] || 200), - (alignMode === "Proportional" ? getAnchorY(srcNode, originSlot, true) : srcNode.pos[1]), - 20, - occupiedSetPositions, - alignMode - ); - - const setNode = createNode(graph, SET_TYPE, setPos[0], setPos[1]); - if (!setNode) continue; - // If the source is inside a hidden/disabled group, the created AutoLink must follow. - _iamccsApplyGroupStateToNode( - graph, - setNode, - srcNode, - { x: setPos[0] + 75, y: setPos[1] + 13 } - ); - - setNode.properties = setNode.properties || {}; - setNode.properties.autolink_color_name = colorSet; - if (setNode.properties.autolink_color_locked === undefined) setNode.properties.autolink_color_locked = false; - applyNodeColors(setNode, getAutolinkColorPreset(colorSet, 'set', separateCol, colorGet)); - applyNodeTitleTextColor(setNode, colorTitles); - - // Ottieni il tipo dall'output del nodo sorgente + // Tipo dell'output sorgente const outputType = srcNode.outputs?.[originSlot]?.type || "*"; let outputSlotName = getSlotName(srcNode, originSlot, true); - - // Ulteriore fix: non permettere mai "*" come chiave if (!outputSlotName || String(outputSlotName).trim() === "*") { if (outputType && outputType !== "*") outputSlotName = String(outputType).trim().toLowerCase(); else outputSlotName = `output_${originSlot}`; } - + console.log(`[IAMCCS AutoLink] Processing: ${srcNode.title || srcNode.type}[${originSlot}] with name "${outputSlotName}"`); - - // Genera nome unico se esiste già un Set con questo nome. - // Importante: NON ridurre mai "model_0" a "model". - const uniqueName = makeUniqueSetName(outputSlotName); - - // Imposta tipo e nome correttamente - if (setNode.inputs && setNode.inputs[0]) { - setNode.inputs[0].type = outputType; - setNode.inputs[0].name = uniqueName; + + // --- CHECK: is there already a Set node consuming this exact source slot? --- + const sourceKey = `${srcNode.id}_${originSlot}`; + const reuseSet = existingSetBySourceKey.get(sourceKey); + + let setNode; + let uniqueName; + + if (reuseSet) { + // Reuse the existing Set node — just add new Get nodes for the new destinations. + setNode = reuseSet; + uniqueName = getAutolinkKey(reuseSet) || makeUniqueSetName(outputSlotName); + console.log(`[IAMCCS AutoLink] ↺ Reusing existing Set node: "${uniqueName}" for ${srcNode.title || srcNode.type}[${originSlot}]`); + // Make sure the type name widget is still consistent + if (setNode.inputs?.[0]) setNode.inputs[0].type = outputType; + if (setNode.outputs?.[0]) setNode.outputs[0].type = outputType; + } else { + // Create a brand-new Set node + const setPos = findFreePosition( + graph, + srcNode.pos[0] + (srcNode.size?.[0] || 200), + (alignMode === "Proportional" ? getAnchorY(srcNode, originSlot, true) : srcNode.pos[1]), + 20, + occupiedSetPositions, + alignMode + ); + + setNode = createNode(graph, SET_TYPE, setPos[0], setPos[1]); + if (!setNode) continue; + + _iamccsApplyGroupStateToNode( + graph, + setNode, + srcNode, + { x: setPos[0] + 75, y: setPos[1] + 13 } + ); + + setNode.properties = setNode.properties || {}; + setNode.properties.autolink_color_name = colorSet; + if (setNode.properties.autolink_color_locked === undefined) setNode.properties.autolink_color_locked = false; + applyNodeColors(setNode, getAutolinkColorPreset(colorSet, 'set', separateCol, colorGet)); + applyNodeTitleTextColor(setNode, colorTitles); + + // Genera nome unico — NON ridurre mai "model_0" a "model" + uniqueName = makeUniqueSetName(outputSlotName); + + if (setNode.inputs?.[0]) { setNode.inputs[0].type = outputType; setNode.inputs[0].name = uniqueName; } + if (setNode.outputs?.[0]) { setNode.outputs[0].type = outputType; setNode.outputs[0].name = uniqueName; } + + setWidgetValue(setNode, "name", uniqueName); + setNode.title = uniqueName; + + console.log(`[IAMCCS AutoLink] ✓ Created Set node: "${uniqueName}" (from ${srcNode.title || srcNode.type})`); + + const nameWidget = getWidget(setNode, "name"); + if (nameWidget) nameWidget.lastValue = uniqueName; + + // Collapse immediately + setTimeout(() => { + if (setNode.collapse) setNode.collapse(); + setNode.size = [150, 26]; + }, 0); + + // Connect source → Set; any existing source→somewhere link on this slot is preserved + // (LiteGraph allows multiple outgoing links; old dstNode links will be replaced when + // we create the Get nodes below and call _iamccsRemoveOtherLinksToTarget). + srcNode.connect(originSlot, setNode, 0); + + // Register for future reuse in this same convertAllLinks call + existingSetBySourceKey.set(sourceKey, setNode); } - if (setNode.outputs && setNode.outputs[0]) { - setNode.outputs[0].type = outputType; - setNode.outputs[0].name = uniqueName; - } - - setWidgetValue(setNode, "name", uniqueName); - setNode.title = `${uniqueName}`; - - console.log(`[IAMCCS AutoLink] ✓ Created Set node: "${uniqueName}" (from ${srcNode.title || srcNode.type})`); - - // Inizializza lastValue per il tracking delle modifiche - const nameWidget = getWidget(setNode, "name"); - if (nameWidget) nameWidget.lastValue = uniqueName; - - // Collassa - setTimeout(() => { - if (setNode.collapse) setNode.collapse(); - setNode.size = [150, 26]; - }, 0); - - // Collega il Set al nodo sorgente - srcNode.connect(originSlot, setNode, 0); - - // Salva il Set creato per creare i Get dopo + + // Salva il Set (nuovo o riutilizzato) per creare i Get dopo createdSets.set(key, { setNode, outputName: uniqueName, @@ -2733,12 +2853,17 @@ function convertAllLinks( getNode.size = [150, 26]; }, 0); + const ts = Number(targetSlot); + const targetInputName = Number.isFinite(ts) ? (dstNode?.inputs?.[ts]?.name ?? null) : null; + // Salva metadata per restore const metadata = { iamccs_autolink: true, output_name: outputName, origin: { id: srcNode.id, slot: originSlot }, - target: { id: dstNode.id, slot: targetSlot } + target: { id: dstNode.id, slot: targetSlot }, + // Used by Restore Direct Links to avoid slot drift + target_input_name: targetInputName, }; setNode.properties = setNode.properties || {}; @@ -2749,7 +2874,6 @@ function convertAllLinks( getNode.properties.metadata = metadata; // Safe rewire: preserve the previous direct link if any. - const ts = Number(targetSlot); if (!Number.isFinite(ts)) { try { graph.remove(getNode); } catch {} continue; @@ -3059,6 +3183,13 @@ function restoreDirectLinks(graph, options = {}) { const ts = Number(targetSlot); if (!Number.isFinite(os) || !Number.isFinite(ts)) return null; + // Safety: never connect to an out-of-range target slot. + // On many LiteGraph/ComfyUI builds this triggers dynamic input creation, + // which shows up as many inactive/empty inputs after Restore. + if (Array.isArray(dstNode?.inputs)) { + if (ts < 0 || ts >= dstNode.inputs.length) return null; + } + try { srcNode.connect(os, dstNode, ts); const ok = _iamccsDidConnect(srcNode, os, dstNode, ts); @@ -3090,6 +3221,26 @@ function restoreDirectLinks(graph, options = {}) { return null; }; + const resolveTargetSlot = (dstNode, md) => { + if (!dstNode) return null; + const inputs = dstNode.inputs; + + // Prefer restoring by input name when available (slot indices can drift). + const targetName = md?.target_input_name; + if (targetName && Array.isArray(inputs)) { + const idx = inputs.findIndex((i) => i?.name === targetName); + if (idx >= 0) return idx; + } + + const slot = md?.target?.slot; + const ts = Number(slot); + if (!Number.isFinite(ts)) return null; + if (Array.isArray(inputs)) { + if (ts < 0 || ts >= inputs.length) return null; + } + return ts; + }; + const isTargetCurrentlyFromGetNode = (dstNode, targetSlot, getNodeId) => { try { const ts = Number(targetSlot); @@ -3136,7 +3287,7 @@ function restoreDirectLinks(graph, options = {}) { const srcNode = getNodeById(graph, origin.id); const dstNode = getNodeById(graph, target.id); const originSlot = origin.slot; - const targetSlot = target.slot; + const ts = resolveTargetSlot(dstNode, md); if (!srcNode || !dstNode) { failed++; @@ -3144,8 +3295,7 @@ function restoreDirectLinks(graph, options = {}) { continue; } - const ts = Number(targetSlot); - if (!Number.isFinite(ts)) { + if (ts == null) { failed++; if (key) keysWithFailures.add(key); continue; @@ -3232,9 +3382,8 @@ function restoreDirectLinks(graph, options = {}) { const dstNode = getNodeById(graph, outLink.target_id); if (!dstNode) continue; - const targetSlot = outLink.target_slot; - const ts = Number(targetSlot); - if (!Number.isFinite(ts)) continue; + const ts = resolveTargetSlot(dstNode, { target: { slot: outLink.target_slot } }); + if (ts == null) continue; // Already correct? Mark restored so we can remove the AutoLink nodes. try { @@ -3369,65 +3518,12 @@ function restoreDirectLinks(graph, options = {}) { console.log("[IAMCCS AutoLink] Extension loaded"); -// ---- Runtime patch: ensure AutoLink graphs can execute ---- -// AutoLink Set/Get nodes are frontend helpers; the backend nodes are no-op. -// To run a workflow, we temporarily restore direct links before queueing, -// then reload the original graph so the user keeps AutoLink nodes. -function _iamccsPatchQueuePromptForAutolink() { - try { - if (app.__iamccs_autolink_queue_patch_installed) return; - if (typeof app?.queuePrompt !== "function") return; - - const originalQueuePrompt = app.queuePrompt; - app.queuePrompt = async function (...args) { - const graph = app?.graph; - const hasAutoLink = _iamccsGraphNodes(graph).some(n => n?.type === SET_TYPE || n?.type === GET_TYPE); - if (!hasAutoLink) { - return await originalQueuePrompt.apply(this, args); - } - - let snapshot = null; - try { - snapshot = typeof graph.serialize === "function" ? graph.serialize() : null; - } catch (e) { - snapshot = null; - } - - try { - // Convert AutoLink nodes into direct links (and remove them) for execution. - // This mutates the live graph, so we restore from snapshot in finally. - try { - restoreDirectLinks(graph, { removeNodes: false, asyncRemove: false, pruneTargetDuplicates: false }); - } catch (e) { - console.warn("[IAMCCS AutoLink] restoreDirectLinks failed before queuePrompt", e); - } - - try { _iamccsFixLinkIntegrity(graph); } catch {} - - return await originalQueuePrompt.apply(this, args); - } finally { - // Restore the original AutoLink graph for the UI. - if (snapshot) { - try { - if (typeof app.loadGraphData === "function") { - await app.loadGraphData(snapshot); - } else if (typeof graph?.configure === "function") { - graph.configure(snapshot); - graph.setDirtyCanvas?.(true, true); - } - } catch (e) { - console.warn("[IAMCCS AutoLink] Failed to restore graph snapshot after queuePrompt", e); - } - } - } - }; - - app.__iamccs_autolink_queue_patch_installed = true; - console.log("[IAMCCS AutoLink] Patched app.queuePrompt (temporary restore for execution)"); - } catch (e) { - console.warn("[IAMCCS AutoLink] Failed to patch queuePrompt", e); - } -} - -_iamccsPatchQueuePromptForAutolink(); +// Queue patch REMOVED. +// AutoLink Set/Get nodes use isVirtualNode = true + getInputLink(), which is the same +// mechanism as KJ SetNode/GetNode. ComfyUI's graphToPrompt() traces through virtual +// nodes transparently, so no live-graph mutation is needed before queueing. +// The old patch was destructive: it called restoreDirectLinks() (mutating the graph), +// then reloaded the graph via loadGraphData() on every queue, causing duplicated inputs +// on Get nodes and broken link chains. +console.log("[IAMCCS AutoLink] Queue execution via isVirtualNode/getInputLink (no patch needed)");